package hostops import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "encoding/json" "errors" "os" "time" "github.com/carlosframework/platform/internal/release" ) // The shared mail-resource gate serializes every enabled SES, DNS and // email-index mutation deployment-wide. It has no expiry and no takeover: // a gate retained after an uncertain effect waits for fenced // reconciliation. Possession is proved by a secret token whose SHA-256 is // stored; reading the gate object is not possession. type MailGateOwner struct{ Console, Enrollment, Controller string } type MailGateScope struct{ Account, App, AppID, Operation string } type MailGateLease struct { ID string Owner MailGateOwner Scope MailGateScope token string // revision of the held state this lease wrote revision uint64 } type mailGate struct { V int `json:"v"` Revision uint64 `json:"revision"` State string `json:"state"` ID string `json:"id"` Owner MailGateOwner `json:"owner"` Scope MailGateScope `json:"scope"` TokenSHA256 string `json:"token_sha256"` HeldAt time.Time `json:"held_at,omitempty"` // Members is every app operation/effect this hold admitted, recorded // BEFORE the member's operation or effect exists, so a reconciler finds // every effect the hold could have issued without scanning admissions. Members []MailGateMember `json:"members,omitempty"` // Reconciling is set by the reconciler's fencing CAS (revision bumped, // token cleared): no lease of the old holder checks any more. Reconciling bool `json:"reconciling,omitempty"` // Reconciled is the evidence of the last fenced reconciliation. Reconciled *MailGateReconciliation `json:"reconciled,omitempty"` } // MailGateMember names one app effect admitted under a held gate. type MailGateMember struct { Account string `json:"account"` App string `json:"app"` AppID string `json:"app_id"` Operation string `json:"operation"` Effect string `json:"effect"` } type mailGateLeaseKey struct{} func WithMailGateLease(ctx context.Context, l *MailGateLease) context.Context { return context.WithValue(ctx, mailGateLeaseKey{}, l) } func MailGateLeaseFrom(ctx context.Context) (*MailGateLease, bool) { l, ok := ctx.Value(mailGateLeaseKey{}).(*MailGateLease) return l, ok && l != nil } func (w *ControlWriter) readMailGate(ctx context.Context) (mailGate, string, error) { body, tag, err := w.raw.Get(ctx, MailGateKey) if errors.Is(err, os.ErrNotExist) { return mailGate{V: 1, State: "free"}, "", nil } if err != nil { return mailGate{}, "", err } var g mailGate if err = json.Unmarshal(body, &g); err != nil { return mailGate{}, "", err } if g.V != 1 || g.Revision == 0 || (g.State != "free" && g.State != "held") || tag == "" { return mailGate{}, "", ErrIdentity } return g, tag, nil } func tokenDigest(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } func (w *ControlWriter) AcquireMailGate(ctx context.Context, owner MailGateOwner, scope MailGateScope) (*MailGateLease, error) { if err := w.ready(); err != nil { return nil, err } if owner.Console == "" || owner.Enrollment == "" || owner.Controller == "" { return nil, ErrIdentity } g, tag, err := w.readMailGate(ctx) if err != nil { return nil, err } if g.State != "free" { return nil, ErrPending } var id, secret [16]byte if _, err = rand.Read(id[:]); err != nil { return nil, err } if _, err = rand.Read(secret[:]); err != nil { return nil, err } token := hex.EncodeToString(secret[:]) g.Revision++ g.State, g.ID, g.Owner, g.Scope, g.TokenSHA256, g.HeldAt = "held", hex.EncodeToString(id[:]), owner, scope, tokenDigest(token), w.now().UTC() g.Members, g.Reconciling = nil, false body, err := json.Marshal(g) if err != nil { return nil, err } if _, err = w.raw.Put(ctx, MailGateKey, body, tag); err != nil { return nil, err } return &MailGateLease{ID: g.ID, Owner: owner, Scope: scope, token: token, revision: g.Revision}, nil } // CheckMailGate proves the exact held revision belongs to this lease. func (w *ControlWriter) CheckMailGate(ctx context.Context, l *MailGateLease) error { if err := w.ready(); err != nil { return err } if l == nil || l.token == "" { return ErrPending } g, _, err := w.readMailGate(ctx) if err != nil { return err } if g.State != "held" || g.ID != l.ID || g.Owner != l.Owner || g.Revision != l.revision || subtle.ConstantTimeCompare([]byte(g.TokenSHA256), []byte(tokenDigest(l.token))) != 1 { return ErrPending } return nil } // ReleaseMailGate frees the gate only from this lease's exact held revision. // Callers release only after every issued effect under the gate settled // complete; an uncertain effect retains the gate. func (w *ControlWriter) ReleaseMailGate(ctx context.Context, l *MailGateLease) error { if err := w.CheckMailGate(ctx, l); err != nil { return err } g, tag, err := w.readMailGate(ctx) if err != nil { return err } if g.Revision != l.revision { return ErrPending } g.Revision++ g.State, g.TokenSHA256, g.HeldAt, g.Members = "free", "", time.Time{}, nil body, err := json.Marshal(g) if err != nil { return err } _, err = w.raw.Put(ctx, MailGateKey, body, tag) return err } // RecordMailGateMember durably names one app effect this lease is about to // admit. It must succeed before the member's BeginExclusive/ResumeExclusive // and Issue. The revision is unchanged (the ETag CAS orders it), so the // lease keeps checking. func (w *ControlWriter) RecordMailGateMember(ctx context.Context, l *MailGateLease, m MailGateMember) error { if m.Account == "" || m.App == "" || m.AppID == "" || m.Operation == "" || m.Effect == "" { return ErrIdentity } for attempt := 1; ; attempt++ { if err := w.CheckMailGate(ctx, l); err != nil { return err } g, tag, err := w.readMailGate(ctx) if err != nil { return err } if g.Revision != l.revision || g.Reconciling { return ErrPending } for _, have := range g.Members { if have == m { return nil } } g.Members = append(g.Members, m) body, err := json.Marshal(g) if err != nil { return err } if _, err = w.raw.Put(ctx, MailGateKey, body, tag); err == nil || !errors.Is(err, release.ErrCASConflict) || attempt >= casRetries { return err } } }