package edge // The door-key mirror (#175 P7): every edge's copy of the signed door-key // records under control/door-keys/, verified against the deployment's root // release key, so the edge-local door can check a key without a bucket read // on the request path. // // It runs its own loop rather than riding the home map's, because the P7 // plan's freshness contract is stated in its own numbers: // // - a record absent from a successful list is dropped at once, so a key // whose record is gone stops working within one interval (30 s); // - a failed list keeps the last map, but clears all of it ten minutes after // the last successful list, so a revocation is honoured within ten minutes // even when the bucket cannot be listed; // - a failed GET keeps that one record under the same ten minutes, measured // from its own last successful read; // - a record that fails verification (signature, shape, expiry), or whose // payload names another account or app than its path, is dropped at once: // it is not a blip but a wrong object, and carrying the old one forward // would let a bucket writer pin it; // - a revision below the one this process last admitted is kept out, and the // newer record kept while it is unexpired. In memory only: a restart // admits whatever validly signed record the bucket holds, until its own // expires_at (the plan's Security model states that residual). // // Record also applies the expiry and the ten-minute bound at read time, so // the contract holds between refreshes and if the loop stops. import ( "context" "crypto/ed25519" "errors" "fmt" "log" "os" "strings" "sync" "time" "github.com/carlosframework/platform/internal/doorkey" "github.com/carlosframework/platform/internal/release" ) // Vars so tests can shorten them. var ( // doorKeyInterval is the mirror's poll: a minted key is admitted, and a // revoked one refused, within one interval of the record landing. doorKeyInterval = 30 * time.Second // doorKeyTimeout is one refresh's budget. doorKeyTimeout = 10 * time.Second ) // doorKeyStaleAfter is how long a record outlives the last time the bucket // vouched for it: a failed list clears the whole map this long after the last // successful one, and a failed GET drops its record this long after its last // successful read. const doorKeyStaleAfter = 10 * time.Minute // mirroredDoorKey is one admitted record and when the bucket last vouched // for it. type mirroredDoorKey struct { rec doorkey.Record readAt time.Time } type doorKeyMirror struct { s release.Store pub ed25519.PublicKey // now is the clock, injectable for the ten-minute bounds. now func() time.Time // refreshMu serializes Refresh, the home map's reason: two overlapping // polls could otherwise publish out of order. high and said are touched // only under it. refreshMu sync.Mutex // high is the highest revision this process has admitted per // "/". It survives the record being dropped. high map[string]uint64 // said is the last refusal logged per record key, so a standing refusal // is logged once rather than every poll. said map[string]string mu sync.RWMutex records map[string]mirroredDoorKey lastList time.Time } func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey) *doorKeyMirror { return &doorKeyMirror{s: s, pub: pub, now: time.Now, high: map[string]uint64{}, said: map[string]string{}, records: map[string]mirroredDoorKey{}} } func doorKeyMapKey(account, app string) string { return account + "/" + app } // doorKeyPath splits a listed key into the account and app its path names. // Anything that is not exactly doorkey.RecordKey(account, app) is not a // record this deployment's console wrote. func doorKeyPath(key string) (account, app string, ok bool) { rest, ok := strings.CutPrefix(key, doorkey.Prefix) if !ok { return "", "", false } rest, ok = strings.CutSuffix(rest, ".json") if !ok { return "", "", false } account, app, ok = strings.Cut(rest, "/") if !ok || account == "" || app == "" || strings.Contains(app, "/") { return "", "", false } return account, app, true } // Refresh rebuilds the map from one list plus a GET per record. A failed list // or GET is returned; the map it leaves follows the rules in the file header. func (d *doorKeyMirror) Refresh(ctx context.Context) error { d.refreshMu.Lock() defer d.refreshMu.Unlock() now := d.now() keys, err := d.s.List(ctx, doorkey.Prefix) if err != nil { d.mu.Lock() if len(d.records) > 0 && now.Sub(d.lastList) >= doorKeyStaleAfter { log.Printf("edge: door-key mirror cleared. No successful list of %s since %s", doorkey.Prefix, d.lastList.Format(time.RFC3339)) d.records = map[string]mirroredDoorKey{} } d.mu.Unlock() return fmt.Errorf("list %s: %w", doorkey.Prefix, err) } prev := d.snapshot() next := make(map[string]mirroredDoorKey, len(keys)) var getErrs []error for _, key := range keys { account, app, ok := doorKeyPath(key) if !ok { continue } mk := doorKeyMapKey(account, app) wire, _, err := d.s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { // Deleted between the list and the GET: absent. continue } if err != nil { getErrs = append(getErrs, fmt.Errorf("get %s: %w", key, err)) if old, ok := prev[mk]; ok && now.Sub(old.readAt) < doorKeyStaleAfter { next[mk] = old } continue } rec, err := doorkey.Verify(d.pub, wire, now) if err == nil && (rec.Account != account || rec.App != app) { err = fmt.Errorf("the record names %s/%s", rec.Account, rec.App) } if err != nil { d.sayOnce(key, fmt.Sprintf("edge: door-key record %s refused and dropped: %v", key, err)) continue } if rec.Revision < d.high[mk] { d.sayOnce(key, fmt.Sprintf("edge: door-key record %s is revision %d, below revision %d already admitted. Keeping the newer one", key, rec.Revision, d.high[mk])) // The bucket answered and holds nothing newer than what this // process has, so the newer record is vouched for as of now, // while it is unexpired. if old, ok := prev[mk]; ok && now.Before(old.rec.ExpiresAt) { next[mk] = mirroredDoorKey{rec: old.rec, readAt: now} } continue } delete(d.said, key) d.high[mk] = rec.Revision next[mk] = mirroredDoorKey{rec: rec, readAt: now} } d.mu.Lock() d.records = next d.lastList = now d.mu.Unlock() return errors.Join(getErrs...) } // sayOnce logs line unless it is the line last logged for key. func (d *doorKeyMirror) sayOnce(key, line string) { if d.said[key] == line { return } d.said[key] = line log.Print(line) } func (d *doorKeyMirror) snapshot() map[string]mirroredDoorKey { d.mu.RLock() defer d.mu.RUnlock() return d.records } // Record is the admitted record for account/app. It refuses a record whose // expiry has passed, or that the bucket has not vouched for in the last ten // minutes, at read time. Nil-safe: a box with no mirror admits nothing. func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool) { if d == nil { return doorkey.Record{}, false } d.mu.RLock() e, ok := d.records[doorKeyMapKey(account, app)] d.mu.RUnlock() if !ok { return doorkey.Record{}, false } now := d.now() if !now.Before(e.rec.ExpiresAt) || now.Sub(e.readAt) >= doorKeyStaleAfter { return doorkey.Record{}, false } return e.rec, true } // run polls until stop closes: the edge's standing loop shape (homeMap.run), // with a 10 s budget per refresh. func (d *doorKeyMirror) run(stop <-chan struct{}) { log.Printf("edge: door-key mirror armed (%s poll of %s*)", doorKeyInterval, doorkey.Prefix) tick := time.NewTicker(doorKeyInterval) defer tick.Stop() errs := 0 for { select { case <-stop: return case <-tick.C: } ctx, cancel := context.WithTimeout(context.Background(), doorKeyTimeout) err := d.Refresh(ctx) cancel() if err != nil { errs++ if errs == 1 || errs%10 == 0 { log.Printf("edge: door-key mirror refresh failed (#%d): %v. Records stay admitted for at most %s after the bucket last vouched for them", errs, err, doorKeyStaleAfter) } continue } if errs > 0 { log.Printf("edge: door-key mirror refresh recovered after %d failure(s)", errs) errs = 0 } } }