geoip: CONSOLE_GEOIP_DB override obeys D1 (Swappable, refused when expired, closed at expiry); verdict judges expiry by the clock; bounds 12/13 min; spec rev 11 (Astra round 10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+180
−13
cmd/console/main.go+54 −1cmd/console/region_test.go+41 −0docs/site/cli.md+2 −2docs/superpowers/specs/2026-10-06-platform-geoip-design.md+21 −6docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R10.md+30 −0internal/console/apisystemgeoip.go+5 −2internal/console/apisystemgeoip_test.go+25 −0system_geoip.go+2 −2
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 9fb3b7eff..2da97bb5e 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -90,6 +90,7 @@ import ( |
| "github.com/carlosframework/platform/internal/console" |
| "github.com/carlosframework/platform/internal/dnsrec" |
| "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/geoip" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| @@ -1146,7 +1147,22 @@ func appRegionsSwitch(v string) bool { return strings.EqualFold(strings.TrimSpac |
| // openGeoIP opens the optional CONSOLE_GEOIP_DB. Unset, unopenable or not |
| // an mmdb means the IP step is off, logged once and never fatal. Returns an |
| // untyped nil on every failure, never a nil *geo.MMDB in the interface. |
| +// |
| +// The mapping sits behind a geo.Swappable so D1 can close it (geoip spec §0 |
| +// D1, §11.3, codex R10-1): a GeoLite2 override already past its 30 days is |
| +// refused here, and one that expires while the console runs is closed by |
| +// watchGeoIPExpiry. The file is the operator's; the platform never deletes |
| +// it, and the log says to replace it. |
| func openGeoIP(path string) geo.Locator { |
| + sw := openGeoIPAt(path, time.Now()) |
| + if sw == nil { |
| + return nil |
| + } |
| + go watchGeoIPExpiry(sw, path, time.Minute, time.Now) |
| + return sw |
| +} |
| + |
| +func openGeoIPAt(path string, now time.Time) *geo.Swappable { |
| if path == "" { |
| return nil |
| } |
| @@ -1155,8 +1171,45 @@ func openGeoIP(path string) geo.Locator { |
| log.Printf("console: CONSOLE_GEOIP_DB %s is unusable, so the IP step of region guesses is off: %v", path, firstLineCapped(err)) |
| return nil |
| } |
| + if geoipExpired(m.Source(), m.BuildEpoch(), now) { |
| + m.Close() |
| + log.Printf("console: CONSOLE_GEOIP_DB %s is a GeoLite2 database more than 30 days old; it was not opened. replace it with a current edition and destroy the old file", path) |
| + return nil |
| + } |
| log.Printf("console: GeoIP database loaded from %s", path) |
| - return m |
| + sw := geo.NewSwappable() |
| + sw.Swap(m) |
| + return sw |
| +} |
| + |
| +// watchGeoIPExpiry closes the override once D1 says so. It returns when it |
| +// has closed it (nothing left to watch). |
| +func watchGeoIPExpiry(sw *geo.Swappable, path string, every time.Duration, now func() time.Time) { |
| + t := time.NewTicker(every) |
| + defer t.Stop() |
| + for range t.C { |
| + if geoIPExpiryTick(sw, path, now()) { |
| + return |
| + } |
| + } |
| +} |
| + |
| +// geoipExpired is D1's rule; a var so a test can apply it to the GeoIP2 |
| +// fixtures, which never expire. |
| +var geoipExpired = geoip.Expired |
| + |
| +// geoIPExpiryTick is one check; true once the reader has been closed. |
| +func geoIPExpiryTick(sw *geo.Swappable, path string, now time.Time) bool { |
| + src, build, ok := sw.Loaded() |
| + if !ok { |
| + return true |
| + } |
| + if !geoipExpired(src, build, now) { |
| + return false |
| + } |
| + sw.Swap(nil) |
| + log.Printf("console: CONSOLE_GEOIP_DB %s expired (GeoLite2, 30 days after build) and was closed. replace it with a current edition, destroy the old file, and restart", path) |
| + return true |
| } |
| // maxLoggedErrBytes caps what firstLineCapped returns. |
diff --git a/cmd/console/region_test.go b/cmd/console/region_test.go| index 37b8ddf6c..4f52a4275 100644 |
| --- a/cmd/console/region_test.go |
| +++ b/cmd/console/region_test.go |
| @@ -2,10 +2,14 @@ package main |
| import ( |
| "errors" |
| + "net" |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| "unicode/utf8" |
| + |
| + "github.com/carlosframework/platform/internal/geoip" |
| ) |
| func TestOpenGeoIPIsOptionalAndNeverFatal(t *testing.T) { |
| @@ -53,3 +57,40 @@ func TestFirstLineCapped(t *testing.T) { |
| t.Errorf("cut through a rune: len %d valid %v", len(got), utf8.ValidString(got)) |
| } |
| } |
| + |
| +// Codex R10-1: the override obeys D1. The repo's fixtures are GeoIP2 (they |
| +// never expire), so the tick is driven with a clock far in the future and |
| +// checked to leave them alone, and the Expired rule itself is geoip's own, |
| +// tested there. |
| +func TestGeoIPOverrideExpiryTick(t *testing.T) { |
| + sw := openGeoIPAt("../../internal/geo/testdata/GeoIP2-City-Test.mmdb", time.Now()) |
| + if sw == nil { |
| + t.Fatal("fixture did not open") |
| + } |
| + if geoIPExpiryTick(sw, "x", time.Now().Add(400*24*time.Hour)) { |
| + t.Fatal("a GeoIP2 database must never expire") |
| + } |
| + if _, ok := sw.Locate(net.ParseIP("81.2.69.142")); !ok { |
| + t.Fatal("the override stopped answering") |
| + } |
| + sw.Swap(nil) |
| + if !geoIPExpiryTick(sw, "x", time.Now()) { |
| + t.Fatal("an empty reader leaves nothing to watch") |
| + } |
| +} |
| + |
| +func TestGeoIPOverrideIsClosedAtExpiryAndRefusedWhenExpired(t *testing.T) { |
| + const fx = "../../internal/geo/testdata/GeoIP2-City-Test.mmdb" |
| + sw := openGeoIPAt(fx, time.Now()) |
| + geoipExpired = func(string, time.Time, time.Time) bool { return true } |
| + t.Cleanup(func() { geoipExpired = geoip.Expired }) |
| + if !geoIPExpiryTick(sw, fx, time.Now()) { |
| + t.Fatal("an expired override was not closed") |
| + } |
| + if _, ok := sw.Locate(net.ParseIP("81.2.69.142")); ok { |
| + t.Fatal("region guesses still use an expired override") |
| + } |
| + if openGeoIPAt(fx, time.Now()) != nil { |
| + t.Fatal("an already-expired override was opened at startup") |
| + } |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 2ad1175c2..3c373b5f6 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1815,7 +1815,7 @@ carlos system geoip off |
| `publish` sends a MaxMind-format database, DB-IP Lite or GeoLite2, to the |
| console. The console checks it, stores it in the deployment bucket, and every |
| -edge picks it up within 11 minutes with no restart. From then on, every |
| +edge picks it up within 12 minutes with no restart. From then on, every |
| request to every app carries the headers described under |
| [Headers your app receives](#headers-your-app-receives), and analytics count |
| page views by country. `publish` refuses a file that isn't a valid database, |
| @@ -1833,7 +1833,7 @@ forged headers straight through. |
| A box that has been retired but still has an edges record keeps `status` from |
| saying `ready`. Deleting its record (`control/edges/<label>.json`) retires it. |
| -`off` removes the database. Every edge stops sending the headers within 11 |
| +`off` removes the database. Every edge stops sending the headers within 12 |
| minutes. |
| **The licence is yours to keep.** DB-IP Lite is licensed under CC BY 4.0 |
diff --git a/docs/superpowers/specs/2026-10-06-platform-geoip-design.md b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md| index 8a24c5d3e..3173235d0 100644 |
| --- a/docs/superpowers/specs/2026-10-06-platform-geoip-design.md |
| +++ b/docs/superpowers/specs/2026-10-06-platform-geoip-design.md |
| @@ -1,7 +1,7 @@ |
| # Platform GeoIP for apps: headers, a mirrored database, a lookup endpoint |
| **Date:** 2026-10-06 |
| -**Revision:** 10 |
| +**Revision:** 11 |
| **Discussion:** amadan carlos/platform #241 (quoted in Appendix A) |
| **Branch:** `geoip-for-apps` |
| @@ -74,6 +74,11 @@ |
| - pointer reads are bounded at 30 seconds, and the off bound includes |
| them; |
| - an unreadable heartbeat blocks only through the expected box it hides. |
| +- Rev 11 answers Astra's round 10 (`CODEX-FINDINGS-GEOIP-R10.md`): |
| + - the `CONSOLE_GEOIP_DB` override's D1 expiry moves into plan 1; |
| + - the off bounds read 11.5 and 12.5 minutes (user copy rounds them up to |
| + 12 and 13); |
| + - the verdict judges expiry by the clock. |
| ## 0. Rulings and decisions |
| @@ -409,9 +414,11 @@ Under `carlos system`, through the console API: |
| - `carlos system geoip off` deletes the pointer, then sweeps the blobs |
| (§3). While a publish holds the slot (an upload streaming in, or its |
| job), `off` answers **409 straight away** rather than waiting behind it |
| - (codex R9-2). The operator runs it again once the publish finishes. Every edge drops its reader within one poll (at most 11 minutes, |
| - with jitter). The console follows its own box's edge within a further |
| - minute, so it takes at most 12 minutes (§11.3, codex R2-9). If |
| + (codex R9-2). The operator runs it again once the publish finishes. Every edge drops its reader within one poll: at most 11.5 minutes, |
| + with jitter and one bounded pointer read, while pointer reads succeed |
| + (§5.3). The console follows its own box's edge within a further minute, |
| + so it takes at most 12.5 minutes (§11.3, codex R2-9, R10). User-facing |
| + copy rounds these up to 12 and 13 minutes. If |
| `CONSOLE_GEOIP_FEED` is set, `off` warns that the feed will publish |
| again within a day and says to unset it. |
| @@ -613,7 +620,7 @@ blocking line uses the pair. Lines name a fleet box as `<fleet>/<label>`. |
| | Pointer | Each expected mirroring box must report | |
| |---|---| |
| | live | the pointer's sha | |
| -| absent (off), or expired | no generation loaded | |
| +| absent (off), or expired (marked, or past D1 by the clock, codex R10) | no generation loaded | |
| | unreadable | nothing satisfies this: the verdict is never ready while the pointer can't be read | |
| **Which boxes are listed, and which can block:** |
| @@ -1249,7 +1256,7 @@ Plans 2 and 3 roll the console, and plan 2 also rolls the boxes for the |
| | Plan | Contents | |
| |---|---| |
| -| 1 | The `control/geoip/` lifecycle rule in `infra/modules/deployment-buckets` (applied later, §15); `geo.Lookup`/`OpenMMDBTrusted`/`Swappable`/`Snapshot`; `release.PutStreamer` and its wrappers; the console publish path and `carlos system geoip publish/status/off`; the edge mirror, its local layout and box status; capture/decide/expose ordering, ingress computing, adopting, `X-Carlos-Hop-Geo`, `setGeo`, the rejected-hop rule and the transport-independent tunnel mark (D2); GeoLite2 expiry; analytics `Countries`/`GeoSources` through to the console table; operator and app docs | |
| +| 1 | The `control/geoip/` lifecycle rule in `infra/modules/deployment-buckets` (applied later, §15); `geo.Lookup`/`OpenMMDBTrusted`/`Swappable`/`Snapshot`; `release.PutStreamer` and its wrappers; the console publish path and `carlos system geoip publish/status/off`; the edge mirror, its local layout and box status; capture/decide/expose ordering, ingress computing, adopting, `X-Carlos-Hop-Geo`, `setGeo`, the rejected-hop rule and the transport-independent tunnel mark (D2); GeoLite2 expiry, including the `CONSOLE_GEOIP_DB` override's (it moves behind a `geo.Swappable`, is refused at startup when already expired, and is closed at expiry, codex R10-1); analytics `Countries`/`GeoSources` through to the console table; operator and app docs | |
| | 2 | The feed (DB-IP and MaxMind sources, bounded input, integrity, type and monotonic checks, `feed.json`) and the `geoip` box rule | |
| | 3 | The console reader on the mirrored generation, the endpoint, `carlos geo` | |
| @@ -1374,6 +1381,14 @@ Round 9 (`CODEX-FINDINGS-GEOIP-R9.md`): every finding accepted. |
| | 1 | P2 | Accepted. Four pointer states: unknown never ready, off/expired requires empty mirrors, live requires the sha (§5.5) | |
| | 2 | P2 | Accepted. `off` answers 409 while a publish holds the slot (§4.2) | |
| +Round 10 (`CODEX-FINDINGS-GEOIP-R10.md`): every finding accepted. |
| + |
| +| # | Sev | Disposition | |
| +|---|---|---| |
| +| R8-2 (partly) | | The bounds read 11.5 and 12.5 minutes, given successful reads (§4.2) | |
| +| R9-1 (partly) | | The verdict treats a pointer past D1 by the clock as expired (§5.5) | |
| +| 1 | P2 | Accepted. Override expiry is in plan 1: a `Swappable`, refused at startup when expired, closed by a ticker (§16) | |
| + |
| ## Appendix A. The request (#241, verbatim) |
| > **Expose the platform's GeoIP lookup to apps (headers and/or lookup endpoint)** |
diff --git a/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R10.md b/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R10.md| new file mode 100644 |
| index 000000000..95a61d945 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/CODEX-FINDINGS-GEOIP-R10.md |
| @@ -0,0 +1,30 @@ |
| +| Previous finding | Disposition | Evidence from revision 10 at `f83276b7` | |
| +|---|---|---| |
| +| R3-1. Expiry/install races | Resolved | §5.3 retains expiry checks before download and installation, with `genMu` serializing selection, expiry and cleanup. | |
| +| R3-2. Unprotected console `Locate` | Resolved | §5.4 specifies locked `Swappable.Locate` and assigns the wrapper to `Config.GeoIP`. | |
| +| R3-3. Coordinate-only results | Resolved | §8.1a accepts coordinate-only hits; §9 counts their country as `_unknown`. | |
| +| R3-4. Heartbeat freshness | Resolved | §5.5 retains `status.BoxStale` and the jittered-heartbeat test. | |
| +| R1-4, carried forward: orphan destruction | Resolved | §3 retains startup and daily orphan sweeps, followed by §15’s noncurrent-version expiration. | |
| +| R1-16, carried forward: rollout verification | Resolved | §5.5 now makes unreadable heartbeats block through missing expected identities and requires every **expected** box to satisfy readiness. | |
| +| R4-1. Duplicate tunnel analytics | Resolved | §§8.4 and 9 retain zero remote observations and exactly one at the hub. | |
| +| R4-2. Publication deadline across backhaul | Resolved | §4.2 retains staging followed by a 202 response and a polled publication job. | |
| +| R5-1. Concurrent upload staging | Resolved | §4.2 retains mutex reservation before reading the body and ownership through job cleanup. | |
| +| R6-1. Plaintext tunnel cache mismatch | Resolved | D2 and §§8–9, 16 consistently require the transport-independent tunnel mark. | |
| +| R6-2. Abandoned upload staging | Resolved | §4.2 retains the dedicated staging directory, startup cleanup and restart tests. | |
| +| R7-1. Publication of expired candidates | Resolved | §4.1 rejects expired candidates, rechecks before pointer replacement and forbids a force override. | |
| +| R7-2. Empty sanitized source | Resolved | §6 retains the valid fallback identifier `mmdb`. | |
| +| R7-3. Attribution lost through overflow | Resolved | §9 exempts attributed sources from folding into `_other`. | |
| +| R7-4. Interface discrepancies | Resolved | §§4.1 and 6 retain the corrected signatures and Unix-seconds conversion. | |
| +| R8-1. Box identity namespaces | Resolved | §5.5 requires `(namespace, label)` throughout inventory, heartbeat matching and blockers. | |
| +| R8-2. Shutdown propagation bound | Partly resolved | §5.3 now bounds pointer reads at 30 seconds and qualifies read failures, giving 11.5 minutes. §4.2 still promises 11 minutes for edges and 12 for the console. Update these to 11.5 and 12.5 minutes, conditional on successful reads. | |
| +| R8-3. Existing-blob verification | Resolved | §3 requires both SHA-256 and size to match before replacing the pointer. | |
| +| R9-1. Readiness without database convergence | Partly resolved | §5.5 defines the required states, but [apisystemgeoip.go:144](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/internal/console/apisystemgeoip.go:144) recognizes expiry only through `cur.Expired`. A database past D1’s deadline remains classified as live until the console marks it. Also evaluate `geoip.Expired` against the current time; test an expired, unmarked pointer with both loaded and empty mirrors. | |
| +| R9-2. `off` waiting behind publication | Resolved | §4.2 explicitly requires immediate 409 while an upload or publication job holds the slot; `Off` now uses `TryLock`. | |
| + |
| +1. **P2 — §§0 D1, 11.3, 15–16: the plan split leaves console-override expiry behind the initial rollout.** D1 requires the console to close an expired `CONSOLE_GEOIP_DB` mapping. §16 includes GeoLite2 expiry in plan 1 but assigns the console reader work to plan 3 without explicitly separating the existing override’s expiry lifecycle. §15 permits deployment after plan 1. An implementer following that division can therefore ship edge expiry while region guesses continue using an expired override indefinitely. |
| + |
| + **Evidence:** at `f83276b7`, [main.go:296](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/cmd/console/main.go:296) still assigns `openGeoIP` directly to `Config.GeoIP`; [main.go:1149](/home/paulca/amadan.net/carlos/platform/.claude/worktrees/geoip-for-apps/cmd/console/main.go:1149) opens and returns the raw mapping without an expiry check or ticker. `internal/console/server.go:1421` starts maintenance for the bucket publisher only; it does not expire that reader. |
| + |
| + **Fix:** explicitly place override expiry in plan 1, while leaving mirrored-file following and the endpoint in plan 3. Require a protected reader, rejection of already-expired overrides at startup, and periodic closure at expiry even when no deployment store is configured. Test startup and expiry transitions through the region-guess caller. |
| + |
| +Verdict: revise. |
| \ No newline at end of file |
diff --git a/internal/console/apisystemgeoip.go b/internal/console/apisystemgeoip.go| index 7c5598546..7ef27ccb0 100644 |
| --- a/internal/console/apisystemgeoip.go |
| +++ b/internal/console/apisystemgeoip.go |
| @@ -141,8 +141,11 @@ func (s *Server) handleAPISystemGeoipGet(w http.ResponseWriter, r *http.Request) |
| case perr != nil: |
| out["pointer_error"] = "the published pointer is unreadable" |
| want.state = geoipUnknown |
| - case present && cur.Expired: |
| - want.state = geoipOff // expired: every edge must have dropped it |
| + case present && (cur.Expired || geoip.Expired(cur.DatabaseType, cur.Build(), time.Now())): |
| + // Expired, marked or not yet marked by the daily job: every edge |
| + // drops it by the clock, so every mirror must be empty (codex R10 |
| + // on R9-1). |
| + want.state = geoipOff |
| case present: |
| want = geoipWant{state: geoipLive, sha: cur.SHA256} |
| } |
diff --git a/internal/console/apisystemgeoip_test.go b/internal/console/apisystemgeoip_test.go| index 62a9789b3..786629b11 100644 |
| --- a/internal/console/apisystemgeoip_test.go |
| +++ b/internal/console/apisystemgeoip_test.go |
| @@ -3,6 +3,7 @@ package console |
| import ( |
| "bytes" |
| "context" |
| + "fmt" |
| "io" |
| "net/http" |
| "net/http/httptest" |
| @@ -357,3 +358,27 @@ func TestGeoipOffDuringAPublishIs409(t *testing.T) { |
| t.Fatal("off waited behind the publish") |
| } |
| } |
| + |
| +// Codex R10 on R9-1: a pointer past D1 by the clock is "off" for the |
| +// verdict even before the daily job marks it. |
| +func TestGeoipStatusTreatsAnUnmarkedExpiredPointerAsOff(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + owner := cliToken(t, fx, "paul@keymail.dev", "code-geoip-expired") |
| + sha := strings.Repeat("c", 64) |
| + ptr := geoip.Pointer{V: 1, SHA256: sha, Key: geoip.BlobKey(sha), Size: 1, DatabaseType: "GeoLite2-City", |
| + BuildEpoch: time.Now().Add(-40 * 24 * time.Hour).Unix(), Origin: "manual"} |
| + b, _ := marshalPointer(ptr) |
| + if _, err := fx.rel.Put(context.Background(), geoip.PointerKey, b, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + seedEdgesRecord(t, fx, "ie-1") |
| + seedGeoBox(t, fx, "ie-1", &status.GeoIPState{Mode: "mirror", SHA256: sha}, time.Now()) |
| + _, out := getJSON(t, fx.ts.URL+"/api/cli/system/geoip", owner) |
| + if out["ready"] != false || !strings.Contains(fmt.Sprint(out["blocking"]), "still has") { |
| + t.Fatalf("expired pointer with a loaded mirror = %v", out) |
| + } |
| + seedGeoBox(t, fx, "ie-1", &status.GeoIPState{Mode: "mirror"}, time.Now()) |
| + if _, out = getJSON(t, fx.ts.URL+"/api/cli/system/geoip", owner); out["ready"] != true { |
| + t.Fatalf("expired pointer with every mirror empty = %v, want ready", out) |
| + } |
| +} |
diff --git a/system_geoip.go b/system_geoip.go| index d175c7aef..bcf440023 100644 |
| --- a/system_geoip.go |
| +++ b/system_geoip.go |
| @@ -53,7 +53,7 @@ func runSystemGeoipPublish(args []string) { |
| if err != nil { |
| log.Fatalf("carlos system geoip publish: %v", err) |
| } |
| - fmt.Fprintf(stdout, "%s %s: %s, built %s. every edge picks it up within 11 minutes\n", |
| + fmt.Fprintf(stdout, "%s %s: %s, built %s. every edge picks it up within 12 minutes\n", |
| colorFor(stdout).ok("published"), short8cli(ptr.SHA256), ptr.DatabaseType, time.Unix(ptr.BuildEpoch, 0).UTC().Format("2006-01-02")) |
| } |
| @@ -258,7 +258,7 @@ func runSystemGeoipOff(args []string) { |
| } |
| func printGeoipOff(w io.Writer, feedSet bool) { |
| - fmt.Fprintln(w, "geoip is off. every edge drops the database within 11 minutes") |
| + fmt.Fprintln(w, "geoip is off. every edge drops the database within 12 minutes") |
| if feedSet { |
| fmt.Fprintln(w, "the feed is still set and will publish again within a day. unset CONSOLE_GEOIP_FEED to keep it off") |
| } |