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
3import (
4 "context"
5 "crypto/sha256"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "io"
11 "log"
12 "math/rand/v2"
13 "os"
14 "path/filepath"
15 "strings"
16 "sync"
17 "time"
18
19 "github.com/carlosframework/platform/internal/geo"
20 "github.com/carlosframework/platform/internal/geoip"
21 "github.com/carlosframework/platform/internal/release"
22)
23
24// The GeoIP mirror (geoip spec §5): poll control/geoip/current.json, fetch
25// the content-addressed blob it names into an immutable local generation,
26// and hot-swap the edge's reader. genMu serialises everything that chooses,
27// expires or deletes a generation (codex R3-1); network I/O stays outside it.
28
29var (
30 // geoPointerReadTimeout bounds every pointer read on its own, so a
31 // slow store cannot stretch the off bound past interval+jitter+this
32 // (codex R9 on R8-2).
33 geoPointerReadTimeout = 30 * time.Second
34 geoMirrorInterval = 10 * time.Minute
35 geoMirrorJitter = time.Minute
36 geoMirrorDeadline = 10 * time.Minute
37 geoExpiryInterval = time.Minute
38)
39
40// geoCurrent is the shared local format (geoip.LocalCurrent), so the
41// console's follower reads exactly what this mirror writes.
42type geoCurrent = geoip.LocalCurrent
43
44type geoMirror struct {
45 store release.Store
46 dir string
47 sw *geo.Swappable
48 now func() time.Time
49 maxBytes int64
50
51 genMu sync.Mutex
52 loadedSHA string
53 loadedType string
54 loadedBuild time.Time
55 skipSHA map[string]string // sha -> why (rejected | expired); cleared whenever the observed pointer changes
56 pointerSHA string // the sha the last readable pointer named; "" for none
57 syncPending bool // the last `current` rename is not known durable
58 unsynced map[string]geoCurrent // every generation a crash could bring back while syncPending; kept until a sync succeeds or it expires
59
60 // The alert's inputs (geoip spec §10.4). The pointer's metadata is
61 // kept after the reader is dropped, so an expired pointer still reads
62 // as expired by the clock before the console marks it (codex R11-1).
63 pointerPresent bool
64 pointerType string
65 pointerBuild time.Time
66 pointerMarked bool
67 wantSince time.Time // a live pointer has named a sha not loaded here since then
68 feedFailing bool
69 feedBox string
70 // pointerKnown and feedKnown are false until a read has succeeded or
71 // confirmed absence: until then the alert's reading is unknown (nil),
72 // so a restart cannot clear an alert the last process raised (code
73 // review R4-2).
74 pointerKnown bool
75 feedKnown bool
76 lastErr string // last poll's error category, "" when healthy
77 failingAt time.Time
78 lastFailLog time.Time
79}
80
81func newGeoMirror(store release.Store, dir string, sw *geo.Swappable) *geoMirror {
82 return &geoMirror{store: store, dir: dir, sw: sw, now: time.Now, maxBytes: geoip.MaxBlobBytes, skipSHA: map[string]string{}}
83}
84
85// GeoIPState is the heartbeat's view of this edge's GeoIP (spec §5.5).
86type GeoIPState struct {
87 Mode string `json:"mode"` // mirror | tunnel | none
88 SHA256 string `json:"sha256,omitempty"`
89 Type string `json:"type,omitempty"`
90 Error string `json:"error,omitempty"`
91}
92
93func (m *geoMirror) State() GeoIPState {
94 m.genMu.Lock()
95 defer m.genMu.Unlock()
96 return GeoIPState{Mode: "mirror", SHA256: m.loadedSHA, Type: m.loadedType, Error: m.lastErr}
97}
98
99func (m *geoMirror) path(sha string) string { return filepath.Join(m.dir, sha+".mmdb") }
100
101// Boot installs the local generation `current` names, if any, without
102// touching the bucket, and clears everything else (spec §5.3 "At boot").
103func (m *geoMirror) Boot() {
104 if err := os.MkdirAll(m.dir, 0o755); err != nil {
105 log.Printf("edge: geoip directory unusable; GeoIP stays off until it is: %v", err)
106 return
107 }
108 os.Chmod(m.dir, 0o755)
109 m.genMu.Lock()
110 defer m.genMu.Unlock()
111 cur, err := m.readCurrent()
112 keep := ""
113 switch {
114 case err != nil && !errors.Is(err, os.ErrNotExist):
115 log.Printf("edge: geoip local state unreadable; starting with no database")
116 os.Remove(filepath.Join(m.dir, "current"))
117 case cur.SHA256 == "":
118 case geoip.Expired(cur.DatabaseType, time.Unix(cur.BuildEpoch, 0), m.now()):
119 log.Printf("edge: geoip database %s expired (GeoLite2, 30 days after build) and was removed", short(cur.SHA256))
120 m.writeCurrentLocked(geoCurrent{})
121 default:
122 db, err := geo.OpenMMDBTrusted(m.path(cur.SHA256))
123 if err != nil {
124 log.Printf("edge: geoip local generation %s unopenable; starting with no database", short(cur.SHA256))
125 os.Remove(filepath.Join(m.dir, "current"))
126 break
127 }
128 m.sw.Swap(db)
129 m.loadedSHA, m.loadedType, m.loadedBuild = cur.SHA256, db.Source(), db.BuildEpoch()
130 keep = cur.SHA256
131 log.Printf("edge: geoip database %s (%s, built %s) loaded", short(cur.SHA256), db.Source(), db.BuildEpoch().Format("2006-01-02"))
132 }
133 m.cleanLocked(keep, true)
134}
135
136func short(sha string) string {
137 if len(sha) > 8 {
138 return sha[:8]
139 }
140 return sha
141}
142
143func (m *geoMirror) readCurrent() (geoCurrent, error) {
144 var c geoCurrent
145 b, err := os.ReadFile(filepath.Join(m.dir, geoip.LocalCurrentFile))
146 if err != nil {
147 return c, err
148 }
149 return geoip.ParseLocalCurrent(b)
150}
151
152// writeCurrentLocked replaces `current` atomically: temp, fsync, rename,
153// fsync the directory (spec §5.2).
154func (m *geoMirror) writeCurrentLocked(c geoCurrent) error {
155 b, _ := json.Marshal(c)
156 tmp, err := os.CreateTemp(m.dir, ".current-*")
157 if err != nil {
158 return err
159 }
160 defer os.Remove(tmp.Name())
161 if _, err := tmp.Write(b); err != nil {
162 tmp.Close()
163 return err
164 }
165 if err := tmp.Sync(); err != nil {
166 tmp.Close()
167 return err
168 }
169 if err := tmp.Close(); err != nil {
170 return err
171 }
172 if err := os.Chmod(tmp.Name(), 0o644); err != nil {
173 return err
174 }
175 if err := os.Rename(tmp.Name(), filepath.Join(m.dir, "current")); err != nil {
176 return err
177 }
178 // After the rename `current` already names the new generation, so a
179 // failed directory fsync must not make the caller delete it (plan review
180 // R1-4): it is logged, and the rename stands. But the rename is not
181 // durable either: a crash could bring back the PREVIOUS `current`, so
182 // m.syncPending tells install to keep the previous generation too until
183 // a later write is durable (code review R1-4).
184 if err := syncDirFn(m.dir); err != nil {
185 log.Printf("edge: geoip directory sync failed after selecting a generation; keeping the earlier ones until it succeeds")
186 m.syncPending = true
187 return nil
188 }
189 m.syncPending, m.unsynced = false, nil
190 return nil
191}
192
193// syncDirFn is syncDir, a variable so a test can inject a failure.
194var syncDirFn = syncDir
195
196func syncDir(dir string) error {
197 d, err := os.Open(dir)
198 if err != nil {
199 return err
200 }
201 defer d.Close()
202 return d.Sync()
203}
204
205// cleanLocked deletes every generation but keep and every stray
206// .current-*. Downloads (.incoming-*) are deleted only at boot: at any other
207// time one may be in progress outside genMu, and unlinking it would break
208// a replacement that an expiry tick happened to race (plan review R1-3).
209func (m *geoMirror) cleanLocked(keep string, atBoot bool) {
210 m.cleanLockedKeeping(atBoot, keep)
211}
212
213// cleanLockedKeeping is cleanLocked with any number of generations kept.
214func (m *geoMirror) cleanLockedKeeping(atBoot bool, keep ...string) {
215 kept := map[string]bool{}
216 for _, k := range keep {
217 if k != "" {
218 kept[k+".mmdb"] = true
219 }
220 }
221 entries, _ := os.ReadDir(m.dir)
222 for _, e := range entries {
223 n := e.Name()
224 if n == "current" || kept[n] {
225 continue
226 }
227 if strings.HasSuffix(n, ".mmdb") || strings.HasPrefix(n, ".current-") || (atBoot && strings.HasPrefix(n, ".incoming-")) {
228 os.Remove(filepath.Join(m.dir, n))
229 }
230 }
231}
232
233// clearLocked turns GeoIP off on this box: empty current, nil reader, no
234// generations.
235func (m *geoMirror) clearLocked() {
236 if err := m.writeCurrentLocked(geoCurrent{}); err != nil {
237 os.Remove(filepath.Join(m.dir, "current"))
238 }
239 m.sw.Swap(nil)
240 m.loadedSHA, m.loadedType, m.loadedBuild = "", "", time.Time{}
241 m.cleanLocked("", false)
242}
243
244// Poll is one pass (spec §5.3). The caller bounds ctx by geoMirrorDeadline.
245func (m *geoMirror) Poll(ctx context.Context) error { return m.poll(ctx, true) }
246
247// poll is one pass; recheck allows ONE immediate re-run when a finished
248// download turns out to be superseded, so an `off` or a newer publish that
249// landed during a long download takes effect now, not a whole interval later
250// (codex R8-2).
251func (m *geoMirror) poll(ctx context.Context, recheck bool) error {
252 // The control-plane state (feed, pointer) is read whatever the local
253 // directory's health, so the alert sees a published database this box
254 // cannot install (code review R2-4).
255 m.readFeedStatus(ctx)
256 raw, err := m.readPointer(ctx)
257 var p geoip.Pointer
258 if err == nil {
259 p, err = geoip.ParsePointer(raw)
260 }
261 if errors.Is(err, os.ErrNotExist) || errors.Is(err, geoip.ErrUnknownVersion) {
262 // No pointer, or one this binary cannot read: GeoIP is off on this
263 // box (spec §3), never "keep what is loaded" (plan review R1-7).
264 m.genMu.Lock()
265 if m.loadedSHA != "" {
266 if errors.Is(err, geoip.ErrUnknownVersion) {
267 log.Printf("edge: geoip turned off; the published pointer has a version this binary cannot read")
268 } else {
269 log.Printf("edge: geoip turned off; no database published")
270 }
271 }
272 m.pointerSHA, m.skipSHA = "", map[string]string{}
273 m.pointerPresent, m.pointerType, m.pointerBuild, m.pointerMarked, m.wantSince = false, "", time.Time{}, false, time.Time{}
274 m.pointerKnown = true
275 m.clearLocked()
276 m.lastErr = ""
277 m.genMu.Unlock()
278 return nil
279 }
280 if raw == nil && err != nil {
281 return m.fail("pointer unreadable", err)
282 }
283 if err != nil {
284 return m.fail("pointer invalid", err)
285 }
286 m.genMu.Lock()
287 m.pointerPresent, m.pointerType, m.pointerBuild, m.pointerMarked = true, p.DatabaseType, p.Build(), p.Expired
288 m.pointerKnown = true
289 switch {
290 case p.Expired || geoip.Expired(p.DatabaseType, p.Build(), m.now()):
291 m.wantSince = time.Time{}
292 case p.SHA256 == m.loadedSHA:
293 m.wantSince = time.Time{}
294 case m.wantSince.IsZero():
295 m.wantSince = m.now()
296 }
297 if p.SHA256 != m.pointerSHA {
298 // The pointer moved (to anything, including back to an sha seen
299 // before): every earlier rejection is void (plan review R1-8).
300 m.pointerSHA, m.skipSHA = p.SHA256, map[string]string{}
301 }
302 if p.Expired || geoip.Expired(p.DatabaseType, p.Build(), m.now()) {
303 if m.skipSHA[p.SHA256] != "expired" {
304 log.Printf("edge: geoip database %s expired (GeoLite2, 30 days after build) and was removed", short(p.SHA256))
305 }
306 m.skipSHA[p.SHA256] = "expired"
307 m.clearLocked()
308 m.lastErr = ""
309 m.genMu.Unlock()
310 return nil
311 }
312 if p.SHA256 == m.loadedSHA {
313 m.lastErr = ""
314 if m.syncPending {
315 // The selection was never made durable: retry the sync, and once
316 // it lands the retained recovery copies can go (code review
317 // R10-2).
318 if err := syncDirFn(m.dir); err == nil {
319 m.syncPending, m.unsynced = false, nil
320 m.cleanLocked(m.loadedSHA, false)
321 }
322 }
323 m.genMu.Unlock()
324 return nil
325 }
326 if _, skip := m.skipSHA[p.SHA256]; skip {
327 m.genMu.Unlock()
328 return nil
329 }
330 m.genMu.Unlock()
331
332 if err := os.MkdirAll(m.dir, 0o755); err != nil {
333 return m.fail("local directory unusable", err)
334 }
335 tmp, err := m.download(ctx, p)
336 if err == nil && !m.pointerStillNames(ctx, p.SHA256) {
337 os.Remove(tmp)
338 if recheck {
339 return m.poll(ctx, false)
340 }
341 return nil
342 }
343 if err != nil {
344 if errors.Is(err, errGeoDigest) || errors.Is(err, errGeoSize) {
345 m.genMu.Lock()
346 m.skipSHA[p.SHA256] = "rejected"
347 m.genMu.Unlock()
348 log.Printf("edge: geoip blob %s rejected: %v", short(p.SHA256), err)
349 }
350 return m.fail("blob unavailable", err)
351 }
352 return m.install(tmp, p)
353}
354
355// downloadHook is a test seam: called after .incoming is created, before
356// the body is copied, so a test can run an expiry tick mid-download.
357var downloadHook func()
358
359// pointerStillNames re-reads the pointer after a download: only a
360// candidate the pointer still selects (unexpired) may be installed. A read
361// failure answers false, which defers the install to the next pass rather
362// than installing something nobody has confirmed is still wanted.
363func (m *geoMirror) pointerStillNames(ctx context.Context, sha string) bool {
364 raw, err := m.readPointer(ctx)
365 if err != nil {
366 return false
367 }
368 p, err := geoip.ParsePointer(raw)
369 return err == nil && p.SHA256 == sha && !p.Expired && !geoip.Expired(p.DatabaseType, p.Build(), m.now())
370}
371
372var (
373 errGeoDigest = errors.New("digest mismatch")
374 errGeoSize = errors.New("size mismatch")
375)
376
377// download streams the blob to .incoming-<sha>, checks size and digest,
378// fsyncs, and returns its path. Outside genMu.
379func (m *geoMirror) download(ctx context.Context, p geoip.Pointer) (string, error) {
380 rc, _, err := release.OpenObject(ctx, m.store, p.Key)
381 if err != nil {
382 return "", err
383 }
384 defer rc.Close()
385 tmp := filepath.Join(m.dir, ".incoming-"+p.SHA256)
386 f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
387 if err != nil {
388 return "", err
389 }
390 limit := p.Size
391 if m.maxBytes < limit {
392 limit = m.maxBytes
393 }
394 if downloadHook != nil {
395 downloadHook()
396 }
397 h := sha256.New()
398 n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(rc, limit+1))
399 if err == nil {
400 err = f.Sync()
401 }
402 if cerr := f.Close(); err == nil {
403 err = cerr
404 }
405 switch {
406 case err != nil:
407 os.Remove(tmp)
408 return "", err
409 case n > limit || n != p.Size:
410 // n > limit: the blob ran past the cap (the box's maxBytes may be
411 // below what the pointer claims), however the counts compare.
412 os.Remove(tmp)
413 return "", errGeoSize
414 case hex.EncodeToString(h.Sum(nil)) != p.SHA256:
415 os.Remove(tmp)
416 return "", errGeoDigest
417 }
418 return tmp, nil
419}
420
421// install commits a verified download under genMu (spec §5.3 steps 3-5),
422// re-checking expiry against the file's own metadata first.
423func (m *geoMirror) install(tmp string, p geoip.Pointer) error {
424 m.genMu.Lock()
425 defer m.genMu.Unlock()
426 final := m.path(p.SHA256)
427 if err := os.Rename(tmp, final); err != nil {
428 os.Remove(tmp)
429 return m.failLocked("install failed", err)
430 }
431 syncDir(m.dir)
432 db, err := geo.OpenMMDBTrusted(final)
433 if err != nil {
434 m.removeGenerationLocked(p.SHA256)
435 m.skipSHA[p.SHA256] = "rejected"
436 log.Printf("edge: geoip blob %s rejected: unopenable", short(p.SHA256))
437 return m.failLocked("blob unopenable", err)
438 }
439 if geoip.Expired(db.Source(), db.BuildEpoch(), m.now()) {
440 db.Close()
441 m.removeGenerationLocked(p.SHA256)
442 m.skipSHA[p.SHA256] = "expired"
443 log.Printf("edge: geoip database %s expired (GeoLite2, 30 days after build) and was removed", short(p.SHA256))
444 return nil
445 }
446 if err := m.writeCurrentLocked(geoCurrent{SHA256: p.SHA256, DatabaseType: db.Source(), BuildEpoch: db.BuildEpoch().Unix()}); err != nil {
447 db.Close()
448 m.removeGenerationLocked(p.SHA256)
449 return m.failLocked("install failed", err)
450 }
451 prev := geoCurrent{SHA256: m.loadedSHA, DatabaseType: m.loadedType, BuildEpoch: m.loadedBuild.Unix()}
452 m.sw.Swap(db)
453 m.loadedSHA, m.loadedType, m.loadedBuild = p.SHA256, db.Source(), db.BuildEpoch()
454 if m.syncPending {
455 // Until a sync succeeds, any `current` since the last durable one
456 // could come back after a crash: keep every one of them (code
457 // review R2 on R1-4).
458 if m.unsynced == nil {
459 m.unsynced = map[string]geoCurrent{}
460 }
461 if prev.SHA256 != "" {
462 m.unsynced[prev.SHA256] = prev
463 }
464 keep := []string{p.SHA256}
465 for sha := range m.unsynced {
466 keep = append(keep, sha)
467 }
468 m.cleanLockedKeeping(false, keep...)
469 } else {
470 m.cleanLocked(p.SHA256, false)
471 }
472 m.lastErr, m.failingAt, m.wantSince = "", time.Time{}, time.Time{}
473 log.Printf("edge: geoip database %s (%s, built %s) loaded", short(p.SHA256), db.Source(), db.BuildEpoch().Format("2006-01-02"))
474 return nil
475}
476
477// removeGenerationLocked deletes a failed install's file unless the same
478// generation is one a crash could bring back (loaded, or kept while syncs
479// fail): republishing a retained A must never delete A on an error path
480// (code review R3 on R1-4).
481func (m *geoMirror) removeGenerationLocked(sha string) {
482 if _, retained := m.unsynced[sha]; sha == m.loadedSHA || retained {
483 return
484 }
485 os.Remove(m.path(sha))
486}
487
488// ExpireTick is D1 on the loaded generation, under genMu, so it can only
489// ever remove the generation that is current at that moment (codex R3-1).
490func (m *geoMirror) ExpireTick() {
491 m.genMu.Lock()
492 defer m.genMu.Unlock()
493 // Retained recovery copies obey D1 too (code review R10-2): an expired
494 // GeoLite2 kept only in case a crash brings it back is destroyed.
495 for sha, g := range m.unsynced {
496 if geoip.Expired(g.DatabaseType, time.Unix(g.BuildEpoch, 0), m.now()) {
497 os.Remove(m.path(sha))
498 delete(m.unsynced, sha)
499 }
500 }
501 if m.loadedSHA == "" || !geoip.Expired(m.loadedType, m.loadedBuild, m.now()) {
502 return
503 }
504 log.Printf("edge: geoip database %s expired (GeoLite2, 30 days after build) and was removed", short(m.loadedSHA))
505 m.skipSHA[m.loadedSHA] = "expired"
506 m.clearLocked()
507}
508
509func (m *geoMirror) fail(category string, err error) error {
510 m.genMu.Lock()
511 defer m.genMu.Unlock()
512 return m.failLocked(category, err)
513}
514
515// failLocked records the category for the heartbeat and logs at most hourly
516// while failing. The error text is never logged: it may carry a key or
517// bytes; the category is the message (spec §12).
518func (m *geoMirror) failLocked(category string, err error) error {
519 now := m.now()
520 if m.lastErr == "" {
521 m.failingAt = now
522 }
523 m.lastErr = category
524 if now.Sub(m.lastFailLog) >= time.Hour {
525 m.lastFailLog = now
526 log.Printf("edge: geoip %s; keeping the loaded database", category)
527 }
528 return fmt.Errorf("%s: %w", category, err)
529}
530
531// readFeedStatus refreshes the feed reason's inputs, best effort: a
532// missing or unreadable feed.json means no feed reason.
533func (m *geoMirror) readFeedStatus(ctx context.Context) {
534 ctx, cancel := context.WithTimeout(ctx, geoPointerReadTimeout)
535 defer cancel()
536 raw, err := readCapped(ctx, m.store, geoip.FeedKey, geoip.MaxPointerBytes)
537 if errors.Is(err, os.ErrNotExist) {
538 m.genMu.Lock()
539 m.feedFailing, m.feedBox, m.feedKnown = false, "", true // no feed: confirmed
540 m.genMu.Unlock()
541 return
542 }
543 if err != nil {
544 return // keep the last known state: a blip must not clear a firing alert (code review R2-3)
545 }
546 st, err := geoip.ParseFeedStatus(raw)
547 if err != nil {
548 return
549 }
550 m.genMu.Lock()
551 m.feedFailing, m.feedBox, m.feedKnown = st.Failing(), st.Box, true
552 m.genMu.Unlock()
553}
554
555// Alert reason codes (geoip spec §10.4), the geoip rule's reading.
556const (
557 GeoAlertNone = 0
558 GeoAlertStale = 1 // the loaded database is over 45 days old
559 GeoAlertExpiring = 2 // a GeoLite2 database within 9 days of D1
560 GeoAlertExpired = 3 // the pointer is expired, marked or by the clock
561 GeoAlertNotLoaded = 4 // a live pointer not loaded here for over an hour
562 GeoAlertFeed = 5 // the feed has failed 7 times in a row (the console's box only)
563)
564
565// AlertCode is the geoip rule's reading for this box: the most severe
566// reason that holds (3, 4, 2, 1, then 5), or 0. known=false until both the
567// pointer and the feed status have been read (or confirmed absent) once.
568func (m *geoMirror) AlertCode(now time.Time, box string) (code float64, known bool) {
569 m.genMu.Lock()
570 defer m.genMu.Unlock()
571 // A reason established from what IS known is reported even when the
572 // other input is not (code review R5-1): an unreadable feed.json must
573 // not hide a confirmed expiry. Only "healthy" needs everything known.
574 if c := m.alertCodeLocked(now, box); c != GeoAlertNone {
575 return c, true
576 }
577 if !m.pointerKnown || !m.feedKnown {
578 return GeoAlertNone, false
579 }
580 return GeoAlertNone, true
581}
582
583func (m *geoMirror) alertCodeLocked(now time.Time, box string) float64 {
584 // pointerPresent is only ever true after a successful read, and
585 // feedFailing only after a parsed feed.json, so each reason below rests
586 // on state that was actually read.
587 expired := m.pointerPresent && (m.pointerMarked || geoip.Expired(m.pointerType, m.pointerBuild, now))
588 switch {
589 case expired:
590 return GeoAlertExpired
591 case m.pointerPresent && !m.wantSince.IsZero() && now.Sub(m.wantSince) > time.Hour:
592 return GeoAlertNotLoaded
593 case m.loadedSHA != "" && strings.HasPrefix(m.loadedType, "GeoLite2-") && now.Sub(m.loadedBuild) >= geoip.GeoLiteWarn:
594 return GeoAlertExpiring
595 case m.loadedSHA != "" && now.Sub(m.loadedBuild) > geoip.StaleAfter:
596 return GeoAlertStale
597 case m.feedFailing && box != "" && m.feedBox == box:
598 return GeoAlertFeed
599 }
600 return GeoAlertNone
601}
602
603// readPointer is one bounded pointer read.
604func (m *geoMirror) readPointer(ctx context.Context) ([]byte, error) {
605 ctx, cancel := context.WithTimeout(ctx, geoPointerReadTimeout)
606 defer cancel()
607 return readCapped(ctx, m.store, geoip.PointerKey, geoip.MaxPointerBytes)
608}
609
610// readCapped reads key through OpenObject, refusing more than max bytes.
611func readCapped(ctx context.Context, s release.Store, key string, max int) ([]byte, error) {
612 rc, _, err := release.OpenObject(ctx, s, key)
613 if err != nil {
614 return nil, err
615 }
616 defer rc.Close()
617 b, err := io.ReadAll(io.LimitReader(rc, int64(max)+1))
618 if err != nil {
619 return nil, err
620 }
621 if len(b) > max {
622 return nil, fmt.Errorf("%s larger than %d bytes", key, max)
623 }
624 return b, nil
625}
626
627// run is the process-lifetime loop: a poll every interval+jitter, each
628// bounded by geoMirrorDeadline, and an independent expiry ticker that never
629// touches the network.
630func (m *geoMirror) run(stop <-chan struct{}) {
631 go func() {
632 t := time.NewTicker(geoExpiryInterval)
633 defer t.Stop()
634 for {
635 select {
636 case <-stop:
637 return
638 case <-t.C:
639 m.ExpireTick()
640 }
641 }
642 }()
643 for {
644 // The next pass is due interval+jitter after this one STARTED, not
645 // after it finished: a pass is bounded by geoMirrorDeadline (10 min),
646 // so a change to the pointer is seen within interval+jitter (11 min)
647 // even when a long download was in flight (codex R8-2).
648 next := time.Now().Add(geoMirrorInterval + rand.N(geoMirrorJitter))
649 ctx, cancel := context.WithTimeout(context.Background(), geoMirrorDeadline)
650 m.Poll(ctx)
651 cancel()
652 select {
653 case <-stop:
654 return
655 case <-time.After(time.Until(next)):
656 }
657 }
658}
659
660// WithGeoTunnelClient marks this edge as a tunnel client's: it never mirrors
661// the GeoIP database and takes the hub's answer instead (spec §5.1).
662func WithGeoTunnelClient() RunOption { return func(e *Edge) { e.geoTunnelClient = true } }
663
664// GeoIPState is the heartbeat block (spec §5.5).
665func (e *Edge) GeoIPState() GeoIPState {
666 if e.geoMirror != nil {
667 return e.geoMirror.State()
668 }
669 if e.geoMode == "" {
670 return GeoIPState{Mode: "none"}
671 }
672 return GeoIPState{Mode: e.geoMode}
673}
674
675// GeoIPAlertCode is the geoip rule's reading for this box (spec §10.4);
676// 0 on an edge with no mirror. known=false means "no reading yet".
677func (e *Edge) GeoIPAlertCode(now time.Time) (float64, bool) {
678 if e.geoMirror == nil {
679 return GeoAlertNone, true
680 }
681 return e.geoMirror.AlertCode(now, e.box)
682}
683