| 1 | package edge |
| 2 | |
| 3 | // The door-key mirror (#175 P7): every edge's copy of the signed door-key |
| 4 | // records under control/door-keys/, verified against the deployment's root |
| 5 | // release key, so the edge-local door can check a key without a bucket read |
| 6 | // on the request path. |
| 7 | // |
| 8 | // It runs its own loop rather than riding the home map's, because the P7 |
| 9 | // plan's freshness contract is stated in its own numbers: |
| 10 | // |
| 11 | // - a record absent from a successful list is dropped at once, so a key |
| 12 | // whose record is gone stops working within one interval (30 s); |
| 13 | // - a failed list keeps the last map, but clears all of it ten minutes after |
| 14 | // the last successful list, so a revocation is honoured within ten minutes |
| 15 | // even when the bucket cannot be listed; |
| 16 | // - a failed GET keeps that one record under the same ten minutes, measured |
| 17 | // from its own last successful read; |
| 18 | // - a record that fails verification (signature, shape, expiry), or whose |
| 19 | // payload names another account or app than its path, is dropped at once: |
| 20 | // it is not a blip but a wrong object, and carrying the old one forward |
| 21 | // would let a bucket writer pin it; |
| 22 | // - a revision below the one this process last admitted is kept out, and the |
| 23 | // newer record kept while it is unexpired. In memory only: a restart |
| 24 | // admits whatever validly signed record the bucket holds, until its own |
| 25 | // expires_at (the plan's Security model states that residual). |
| 26 | // |
| 27 | // Record also applies the expiry and the ten-minute bound at read time, so |
| 28 | // the contract holds between refreshes and if the loop stops. |
| 29 | |
| 30 | import ( |
| 31 | "context" |
| 32 | "crypto/ed25519" |
| 33 | "errors" |
| 34 | "fmt" |
| 35 | "log" |
| 36 | "os" |
| 37 | "strings" |
| 38 | "sync" |
| 39 | "time" |
| 40 | |
| 41 | "github.com/carlosframework/platform/internal/doorkey" |
| 42 | "github.com/carlosframework/platform/internal/release" |
| 43 | ) |
| 44 | |
| 45 | // Vars so tests can shorten them. |
| 46 | var ( |
| 47 | // doorKeyInterval is the mirror's poll: a minted key is admitted, and a |
| 48 | // revoked one refused, within one interval of the record landing. |
| 49 | doorKeyInterval = 30 * time.Second |
| 50 | // doorKeyTimeout is one refresh's budget. |
| 51 | doorKeyTimeout = 10 * time.Second |
| 52 | ) |
| 53 | |
| 54 | // doorKeyStaleAfter is how long a record outlives the last time the bucket |
| 55 | // vouched for it: a failed list clears the whole map this long after the last |
| 56 | // successful one, and a failed GET drops its record this long after its last |
| 57 | // successful read. |
| 58 | const doorKeyStaleAfter = 10 * time.Minute |
| 59 | |
| 60 | // mirroredDoorKey is one admitted record and when the bucket last vouched |
| 61 | // for it. |
| 62 | type mirroredDoorKey struct { |
| 63 | rec doorkey.Record |
| 64 | readAt time.Time |
| 65 | } |
| 66 | |
| 67 | type doorKeyMirror struct { |
| 68 | s release.Store |
| 69 | pub ed25519.PublicKey |
| 70 | // now is the clock, injectable for the ten-minute bounds. |
| 71 | now func() time.Time |
| 72 | |
| 73 | // refreshMu serializes Refresh, the home map's reason: two overlapping |
| 74 | // polls could otherwise publish out of order. high and said are touched |
| 75 | // only under it. |
| 76 | refreshMu sync.Mutex |
| 77 | // high is the highest revision this process has admitted per |
| 78 | // "<account>/<app>". It survives the record being dropped. |
| 79 | high map[string]uint64 |
| 80 | // said is the last refusal logged per record key, so a standing refusal |
| 81 | // is logged once rather than every poll. |
| 82 | said map[string]string |
| 83 | |
| 84 | mu sync.RWMutex |
| 85 | records map[string]mirroredDoorKey |
| 86 | lastList time.Time |
| 87 | } |
| 88 | |
| 89 | func newDoorKeyMirror(s release.Store, pub ed25519.PublicKey) *doorKeyMirror { |
| 90 | return &doorKeyMirror{s: s, pub: pub, now: time.Now, |
| 91 | high: map[string]uint64{}, said: map[string]string{}, records: map[string]mirroredDoorKey{}} |
| 92 | } |
| 93 | |
| 94 | func doorKeyMapKey(account, app string) string { return account + "/" + app } |
| 95 | |
| 96 | // doorKeyPath splits a listed key into the account and app its path names. |
| 97 | // Anything that is not exactly doorkey.RecordKey(account, app) is not a |
| 98 | // record this deployment's console wrote. |
| 99 | func doorKeyPath(key string) (account, app string, ok bool) { |
| 100 | rest, ok := strings.CutPrefix(key, doorkey.Prefix) |
| 101 | if !ok { |
| 102 | return "", "", false |
| 103 | } |
| 104 | rest, ok = strings.CutSuffix(rest, ".json") |
| 105 | if !ok { |
| 106 | return "", "", false |
| 107 | } |
| 108 | account, app, ok = strings.Cut(rest, "/") |
| 109 | if !ok || account == "" || app == "" || strings.Contains(app, "/") { |
| 110 | return "", "", false |
| 111 | } |
| 112 | return account, app, true |
| 113 | } |
| 114 | |
| 115 | // Refresh rebuilds the map from one list plus a GET per record. A failed list |
| 116 | // or GET is returned; the map it leaves follows the rules in the file header. |
| 117 | func (d *doorKeyMirror) Refresh(ctx context.Context) error { |
| 118 | d.refreshMu.Lock() |
| 119 | defer d.refreshMu.Unlock() |
| 120 | now := d.now() |
| 121 | |
| 122 | keys, err := d.s.List(ctx, doorkey.Prefix) |
| 123 | if err != nil { |
| 124 | d.mu.Lock() |
| 125 | if len(d.records) > 0 && now.Sub(d.lastList) >= doorKeyStaleAfter { |
| 126 | log.Printf("edge: door-key mirror cleared. No successful list of %s since %s", doorkey.Prefix, d.lastList.Format(time.RFC3339)) |
| 127 | d.records = map[string]mirroredDoorKey{} |
| 128 | } |
| 129 | d.mu.Unlock() |
| 130 | return fmt.Errorf("list %s: %w", doorkey.Prefix, err) |
| 131 | } |
| 132 | |
| 133 | prev := d.snapshot() |
| 134 | next := make(map[string]mirroredDoorKey, len(keys)) |
| 135 | var getErrs []error |
| 136 | for _, key := range keys { |
| 137 | account, app, ok := doorKeyPath(key) |
| 138 | if !ok { |
| 139 | continue |
| 140 | } |
| 141 | mk := doorKeyMapKey(account, app) |
| 142 | wire, _, err := d.s.Get(ctx, key) |
| 143 | if errors.Is(err, os.ErrNotExist) { |
| 144 | // Deleted between the list and the GET: absent. |
| 145 | continue |
| 146 | } |
| 147 | if err != nil { |
| 148 | getErrs = append(getErrs, fmt.Errorf("get %s: %w", key, err)) |
| 149 | if old, ok := prev[mk]; ok && now.Sub(old.readAt) < doorKeyStaleAfter { |
| 150 | next[mk] = old |
| 151 | } |
| 152 | continue |
| 153 | } |
| 154 | rec, err := doorkey.Verify(d.pub, wire, now) |
| 155 | if err == nil && (rec.Account != account || rec.App != app) { |
| 156 | err = fmt.Errorf("the record names %s/%s", rec.Account, rec.App) |
| 157 | } |
| 158 | if err != nil { |
| 159 | d.sayOnce(key, fmt.Sprintf("edge: door-key record %s refused and dropped: %v", key, err)) |
| 160 | continue |
| 161 | } |
| 162 | if rec.Revision < d.high[mk] { |
| 163 | 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])) |
| 164 | // The bucket answered and holds nothing newer than what this |
| 165 | // process has, so the newer record is vouched for as of now, |
| 166 | // while it is unexpired. |
| 167 | if old, ok := prev[mk]; ok && now.Before(old.rec.ExpiresAt) { |
| 168 | next[mk] = mirroredDoorKey{rec: old.rec, readAt: now} |
| 169 | } |
| 170 | continue |
| 171 | } |
| 172 | delete(d.said, key) |
| 173 | d.high[mk] = rec.Revision |
| 174 | next[mk] = mirroredDoorKey{rec: rec, readAt: now} |
| 175 | } |
| 176 | |
| 177 | d.mu.Lock() |
| 178 | d.records = next |
| 179 | d.lastList = now |
| 180 | d.mu.Unlock() |
| 181 | return errors.Join(getErrs...) |
| 182 | } |
| 183 | |
| 184 | // sayOnce logs line unless it is the line last logged for key. |
| 185 | func (d *doorKeyMirror) sayOnce(key, line string) { |
| 186 | if d.said[key] == line { |
| 187 | return |
| 188 | } |
| 189 | d.said[key] = line |
| 190 | log.Print(line) |
| 191 | } |
| 192 | |
| 193 | func (d *doorKeyMirror) snapshot() map[string]mirroredDoorKey { |
| 194 | d.mu.RLock() |
| 195 | defer d.mu.RUnlock() |
| 196 | return d.records |
| 197 | } |
| 198 | |
| 199 | // Record is the admitted record for account/app. It refuses a record whose |
| 200 | // expiry has passed, or that the bucket has not vouched for in the last ten |
| 201 | // minutes, at read time. Nil-safe: a box with no mirror admits nothing. |
| 202 | func (d *doorKeyMirror) Record(account, app string) (doorkey.Record, bool) { |
| 203 | if d == nil { |
| 204 | return doorkey.Record{}, false |
| 205 | } |
| 206 | d.mu.RLock() |
| 207 | e, ok := d.records[doorKeyMapKey(account, app)] |
| 208 | d.mu.RUnlock() |
| 209 | if !ok { |
| 210 | return doorkey.Record{}, false |
| 211 | } |
| 212 | now := d.now() |
| 213 | if !now.Before(e.rec.ExpiresAt) || now.Sub(e.readAt) >= doorKeyStaleAfter { |
| 214 | return doorkey.Record{}, false |
| 215 | } |
| 216 | return e.rec, true |
| 217 | } |
| 218 | |
| 219 | // run polls until stop closes: the edge's standing loop shape (homeMap.run), |
| 220 | // with a 10 s budget per refresh. |
| 221 | func (d *doorKeyMirror) run(stop <-chan struct{}) { |
| 222 | log.Printf("edge: door-key mirror armed (%s poll of %s*)", doorKeyInterval, doorkey.Prefix) |
| 223 | tick := time.NewTicker(doorKeyInterval) |
| 224 | defer tick.Stop() |
| 225 | errs := 0 |
| 226 | for { |
| 227 | select { |
| 228 | case <-stop: |
| 229 | return |
| 230 | case <-tick.C: |
| 231 | } |
| 232 | ctx, cancel := context.WithTimeout(context.Background(), doorKeyTimeout) |
| 233 | err := d.Refresh(ctx) |
| 234 | cancel() |
| 235 | if err != nil { |
| 236 | errs++ |
| 237 | if errs == 1 || errs%10 == 0 { |
| 238 | 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) |
| 239 | } |
| 240 | continue |
| 241 | } |
| 242 | if errs > 0 { |
| 243 | log.Printf("edge: door-key mirror refresh recovered after %d failure(s)", errs) |
| 244 | errs = 0 |
| 245 | } |
| 246 | } |
| 247 | } |
| 248 | |