| 1 | package relay |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/rand" |
| 6 | "encoding/hex" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "fmt" |
| 10 | "os" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/carlosframework/platform/internal/release" |
| 14 | ) |
| 15 | |
| 16 | // RecordSchema is the current Record schema version. |
| 17 | const RecordSchema = 1 |
| 18 | |
| 19 | // RecordKey is the per-app record's location in the deployment bucket. |
| 20 | func RecordKey(acct, app string) string { |
| 21 | return release.AppPrefix(acct, app) + "relay.json" |
| 22 | } |
| 23 | |
| 24 | // RouteCutover is one member's ack for a TCP exec route (spec §5): the |
| 25 | // address they're cutting to, the per-region suffixes they've picked up, |
| 26 | // and who/when. |
| 27 | type RouteCutover struct { |
| 28 | Host string `json:"host"` |
| 29 | Suffixes map[string]string `json:"suffixes"` // region -> suffix |
| 30 | At time.Time `json:"at"` |
| 31 | By string `json:"by"` |
| 32 | } |
| 33 | |
| 34 | // Record is the per-app relay state (spec §2, §5): whether the relay is |
| 35 | // enabled for the app, its current key incarnation, the regions it's live |
| 36 | // in, the in-flight rotation op (if any), and the bookkeeping a rotation |
| 37 | // needs to converge. |
| 38 | type Record struct { |
| 39 | Schema int `json:"schema"` |
| 40 | Enabled bool `json:"enabled"` |
| 41 | Incarnation string `json:"incarnation"` |
| 42 | Regions []string `json:"regions"` |
| 43 | Op int64 `json:"op"` |
| 44 | Phase string `json:"phase"` // "config"|"aggregate"|"done" |
| 45 | IssuedSuffixes map[string]string `json:"issued_suffixes"` // region -> username_suffix last issued |
| 46 | // IssuedDigest is hex(sha256) of the sealed CARLOS_RELAY envelope the |
| 47 | // console last WROTE into the app's config bundle. Additive (omitempty, |
| 48 | // no schema bump): a record written before this field simply has none, |
| 49 | // and the console's reconcile re-applies once to fill it in. |
| 50 | // |
| 51 | // It exists because the console cannot open the envelope it wrote — it |
| 52 | // holds no decryption key — and two seals of identical plaintext differ |
| 53 | // anyway. Without a digest the reconcile can only compare the record to |
| 54 | // itself, and a bundle carrying a DIFFERENT envelope (a straggler write |
| 55 | // from a superseded console, or a member's own `carlos secrets set |
| 56 | // CARLOS_RELAY`) reads as converged forever. With it, "the bundle holds |
| 57 | // exactly what we last wrote" is a fact the console can check. |
| 58 | IssuedDigest string `json:"issued_digest,omitempty"` |
| 59 | Cutovers []RouteCutover `json:"cutovers"` // member acks for TCP exec routes |
| 60 | EnabledAt time.Time `json:"enabled_at"` |
| 61 | DisabledAt time.Time `json:"disabled_at"` |
| 62 | } |
| 63 | |
| 64 | // GetRecord reads the record. Absent is (nil, "", nil) — same idiom as |
| 65 | // objstore.Get, because "no relay" is a state, not an error. |
| 66 | func GetRecord(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) { |
| 67 | if !release.ValidAccount(acct) { |
| 68 | return nil, "", fmt.Errorf("relay: invalid account %q", acct) |
| 69 | } |
| 70 | if !release.ValidApp(app) { |
| 71 | return nil, "", fmt.Errorf("relay: invalid app %q", app) |
| 72 | } |
| 73 | raw, etag, err := s.Get(ctx, RecordKey(acct, app)) |
| 74 | if err != nil { |
| 75 | if errors.Is(err, os.ErrNotExist) { |
| 76 | return nil, "", nil |
| 77 | } |
| 78 | return nil, "", err |
| 79 | } |
| 80 | var rec Record |
| 81 | if err := json.Unmarshal(raw, &rec); err != nil { |
| 82 | return nil, "", fmt.Errorf("relay: %s: %w", RecordKey(acct, app), err) |
| 83 | } |
| 84 | return &rec, etag, nil |
| 85 | } |
| 86 | |
| 87 | // PutRecord writes the record with the caller's CAS position; etag "" is |
| 88 | // create-only. Every writer is a read-modify-write, so a lost race is |
| 89 | // release.ErrCASConflict for the caller to surface, never to absorb. |
| 90 | func PutRecord(ctx context.Context, s release.Store, acct, app string, r *Record, etag string) (string, error) { |
| 91 | r.Schema = RecordSchema |
| 92 | body, err := json.MarshalIndent(r, "", " ") |
| 93 | if err != nil { |
| 94 | return "", err |
| 95 | } |
| 96 | return s.Put(ctx, RecordKey(acct, app), append(body, '\n'), etag) |
| 97 | } |
| 98 | |
| 99 | // NewIncarnation is 16 lowercase hex characters from crypto/rand — a fresh |
| 100 | // key incarnation, distinct from every prior one with overwhelming |
| 101 | // probability. |
| 102 | func NewIncarnation() string { |
| 103 | var b [8]byte |
| 104 | if _, err := rand.Read(b[:]); err != nil { |
| 105 | panic("relay: crypto/rand: " + err.Error()) |
| 106 | } |
| 107 | return hex.EncodeToString(b[:]) |
| 108 | } |
| 109 | |