carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package 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
30import (
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.
46var (
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.
58const doorKeyStaleAfter = 10 * time.Minute
59
60// mirroredDoorKey is one admitted record and when the bucket last vouched
61// for it.
62type mirroredDoorKey struct {
63 rec doorkey.Record
64 readAt time.Time
65}
66
67type 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
89func 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
94func 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.
99func 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.
117func (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.
185func (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
193func (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.
202func (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.
221func (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