geoip plan 3: the console follows this box's mirrored generation; GET /api/cli/apps/{a}/{app}/geo; carlos geo; attribution from the loaded source (geoip spec §11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+550
−18
cmd/console/main.go+32 −1cmd/console/region_test.go+10 −0geo_cmd.go+100 −0geo_cmd_test.go+35 −0internal/console/apigeo.go+62 −0internal/console/apigeo_test.go+58 −0internal/console/authcli.go+3 −0internal/console/geoipfollower.go+93 −0internal/console/geoipfollower_test.go+92 −0internal/console/regionpicker.go+11 −3internal/console/server.go+19 −0internal/console/templates/app_new.html+1 −1internal/edge/geoipmirror.go+5 −13internal/geoip/pointer.go+26 −0main.go+3 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index da6753b32..30c54ed7b 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -283,6 +283,7 @@ func serve() { |
| } |
| // Before the server exists: every door that seals reads this switch. |
| appconfig.SetBoundSealing(secretsEnvelopeV2()) |
| + geoReader := consoleGeoReader(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))) |
| srv, err := console.NewServer(console.Config{ |
| Origin: origin, |
| Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| @@ -295,7 +296,10 @@ func serve() { |
| Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| HomeRegion: strings.TrimSpace(os.Getenv("CONSOLE_HOME_REGION")), |
| AppRegions: appRegionsSwitch(os.Getenv("CONSOLE_APP_REGIONS")), |
| - GeoIP: openGeoIP(strings.TrimSpace(os.Getenv("CONSOLE_GEOIP_DB"))), |
| + // GeoIP (geoip spec §11.3): the CONSOLE_GEOIP_DB override when set, |
| + // otherwise the follower of this box's own edge mirror. |
| + GeoIP: geoLocator(geoReader), |
| + GeoIPReader: geoReader, |
| // Next to CONSOLE_DB, on disk (an upload can be 1 GiB), and used |
| // for nothing else: NewServer clears it (geoip spec §4.2). |
| GeoipStagingDir: filepath.Join(filepath.Dir(envOr("CONSOLE_DB", "/data/console/console.db")), "geoip-staging"), |
| @@ -1285,3 +1289,30 @@ func consoleBoxLabel() string { |
| } |
| return status.Label(strings.TrimSpace(string(b))) |
| } |
| + |
| +// consoleGeoReader is the console's one GeoIP reader. With CONSOLE_GEOIP_DB |
| +// set it is that override (opened once, D1-checked, closed at expiry); |
| +// otherwise it follows the generation this box's edge mirrors, under |
| +// CARLOS_DATA_DIR (default /data) as carlos/geoip, once a minute. |
| +func consoleGeoReader(override string) *geo.Swappable { |
| + if override != "" { |
| + if loc, ok := openGeoIP(override).(*geo.Swappable); ok { |
| + return loc |
| + } |
| + return nil |
| + } |
| + sw := geo.NewSwappable() |
| + f := console.NewGeoipFollower(filepath.Join(envOr("CARLOS_DATA_DIR", "/data"), "carlos", "geoip"), sw) |
| + go f.Run() |
| + return sw |
| +} |
| + |
| +// geoLocator is sw as a geo.Locator, or an untyped nil: a nil *Swappable in |
| +// the interface would be non-nil to every `GeoIP != nil` check and panic on |
| +// the first Locate. |
| +func geoLocator(sw *geo.Swappable) geo.Locator { |
| + if sw == nil { |
| + return nil |
| + } |
| + return sw |
| +} |
diff --git a/cmd/console/region_test.go b/cmd/console/region_test.go| index 4f52a4275..59efe142f 100644 |
| --- a/cmd/console/region_test.go |
| +++ b/cmd/console/region_test.go |
| @@ -94,3 +94,13 @@ func TestGeoIPOverrideIsClosedAtExpiryAndRefusedWhenExpired(t *testing.T) { |
| t.Fatal("an already-expired override was opened at startup") |
| } |
| } |
| + |
| +func TestConsoleGeoReaderNeverYieldsATypedNil(t *testing.T) { |
| + sw := consoleGeoReader(filepath.Join(t.TempDir(), "missing.mmdb")) |
| + if sw != nil { |
| + t.Fatal("an unusable override must give no reader") |
| + } |
| + if geoLocator(sw) != nil { |
| + t.Fatal("a nil reader must be an untyped nil Locator") |
| + } |
| +} |
diff --git a/geo_cmd.go b/geo_cmd.go| new file mode 100644 |
| index 000000000..e8fc44db5 |
| --- /dev/null |
| +++ b/geo_cmd.go |
| @@ -0,0 +1,100 @@ |
| +// `carlos geo <ip>`: what the platform's GeoIP database says about an |
| +// address (geoip spec §11.2). API mode only. Its strings await copy review. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "os" |
| +) |
| + |
| +const geoUsage = `usage: carlos geo <ip> --app <a> [flags] |
| + |
| +What the platform's GeoIP database says about an address: the same |
| +answer the X-Carlos-Geo-* headers carry, for an address that is not the |
| +current request. --json prints the console's answer as it is. |
| +` |
| + |
| +type apiGeoResponse struct { |
| + Found bool `json:"found"` |
| + Country string `json:"country"` |
| + Subdivision string `json:"subdivision"` |
| + Lat *float64 `json:"lat"` |
| + Lon *float64 `json:"lon"` |
| + Source string `json:"source"` |
| + Attribution string `json:"attribution"` |
| + AttributionURL string `json:"attribution_url"` |
| +} |
| + |
| +func runGeo(args []string) { |
| + fs := flag.NewFlagSet("geo", flag.ExitOnError) |
| + app := appFlag(fs, "app whose credential asks") |
| + asJSON := fs.Bool("json", false, "print the console's answer as JSON") |
| + consoleFlag := consoleOption(fs, "console to ask (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes --account") |
| + fs.Usage = flagUsage(fs, geoUsage) |
| + fs.Parse(ipLast(args)) |
| + if fs.NArg() != 1 { |
| + fs.Usage() |
| + os.Exit(2) |
| + } |
| + sess := requireAPISession("geo", *consoleFlag) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, geoUsage) |
| + path := apiPath(acct, app2, "/geo") + "?ip=" + url.QueryEscape(fs.Arg(0)) |
| + st, body, err := sess.getJSON(context.Background(), path) |
| + if err != nil { |
| + log.Fatalf("geo: %v", err) |
| + } |
| + if st != http.StatusOK { |
| + log.Fatalf("geo: %v", apiErrorFromBody(st, body)) |
| + } |
| + if *asJSON { |
| + stdout.Write(body) |
| + fmt.Fprintln(stdout) |
| + return |
| + } |
| + var res apiGeoResponse |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + log.Fatalf("geo: unreadable response: %v", err) |
| + } |
| + printGeo(stdout, fs.Arg(0), res) |
| +} |
| + |
| +func printGeo(w io.Writer, ip string, res apiGeoResponse) { |
| + if !res.Found { |
| + fmt.Fprintf(w, "%s: no location. the database has no answer for this address\n", ip) |
| + return |
| + } |
| + fmt.Fprintf(w, "%s\n", ip) |
| + if res.Country != "" { |
| + fmt.Fprintf(w, " country %s\n", res.Country) |
| + } |
| + if res.Subdivision != "" { |
| + fmt.Fprintf(w, " subdivision %s\n", res.Subdivision) |
| + } |
| + if res.Lat != nil && res.Lon != nil { |
| + fmt.Fprintf(w, " point %.2f, %.2f (an area, not a position)\n", *res.Lat, *res.Lon) |
| + } |
| + fmt.Fprintf(w, " source %s\n", res.Source) |
| + if res.Attribution != "" { |
| + fmt.Fprintf(w, " credit %s (%s)\n", res.Attribution, res.AttributionURL) |
| + } |
| +} |
| + |
| +// ipLast moves a leading address after the flags, so both |
| +// `carlos geo 1.2.3.4 --app a` and `carlos geo --app a 1.2.3.4` work |
| +// (Go's flag package stops at the first non-flag). |
| +func ipLast(args []string) []string { |
| + if len(args) > 0 && len(args[0]) > 0 && args[0][0] != '-' { |
| + return append(append([]string(nil), args[1:]...), args[0]) |
| + } |
| + return args |
| +} |
diff --git a/geo_cmd_test.go b/geo_cmd_test.go| new file mode 100644 |
| index 000000000..0f4834a74 |
| --- /dev/null |
| +++ b/geo_cmd_test.go |
| @@ -0,0 +1,35 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestIPLast(t *testing.T) { |
| + got := ipLast([]string{"1.2.3.4", "--app", "a"}) |
| + if strings.Join(got, " ") != "--app a 1.2.3.4" { |
| + t.Fatalf("ipLast = %v", got) |
| + } |
| + got = ipLast([]string{"--app", "a", "1.2.3.4"}) |
| + if strings.Join(got, " ") != "--app a 1.2.3.4" { |
| + t.Fatalf("ipLast = %v", got) |
| + } |
| +} |
| + |
| +func TestPrintGeo(t *testing.T) { |
| + lat, lon := 51.51, -0.09 |
| + var out bytes.Buffer |
| + printGeo(&out, "81.2.69.142", apiGeoResponse{Found: true, Country: "GB", Subdivision: "GB-ENG", Lat: &lat, Lon: &lon, |
| + Source: "DBIP-City-Lite", Attribution: "IP Geolocation by DB-IP", AttributionURL: "https://db-ip.com"}) |
| + for _, want := range []string{"country GB", "subdivision GB-ENG", "51.51, -0.09", "IP Geolocation by DB-IP"} { |
| + if !strings.Contains(out.String(), want) { |
| + t.Errorf("output lacks %q:\n%s", want, out.String()) |
| + } |
| + } |
| + out.Reset() |
| + printGeo(&out, "10.0.0.1", apiGeoResponse{}) |
| + if !strings.Contains(out.String(), "no location") { |
| + t.Fatalf("miss = %q", out.String()) |
| + } |
| +} |
diff --git a/internal/console/apigeo.go b/internal/console/apigeo.go| new file mode 100644 |
| index 000000000..fdcb0645c |
| --- /dev/null |
| +++ b/internal/console/apigeo.go |
| @@ -0,0 +1,62 @@ |
| +package console |
| + |
| +// GET /api/cli/apps/{account}/{app}/geo?ip=<addr> (geoip spec §11.1): the |
| +// platform's GeoIP answer for an arbitrary address, for work that is not |
| +// the current request (batch jobs, logs, webhooks). The same fields as the |
| +// X-Carlos-Geo-* headers, plus the attribution the answering database's |
| +// licence requires. The queried address is never logged. |
| + |
| +import ( |
| + "net" |
| + "net/http" |
| + |
| + "github.com/carlosframework/platform/internal/geoip" |
| +) |
| + |
| +type geoResponse struct { |
| + Found bool `json:"found"` |
| + Country string `json:"country,omitempty"` |
| + Subdivision string `json:"subdivision,omitempty"` |
| + Lat *float64 `json:"lat,omitempty"` |
| + Lon *float64 `json:"lon,omitempty"` |
| + Source string `json:"source,omitempty"` |
| + Attribution string `json:"attribution,omitempty"` |
| + AttributionURL string `json:"attribution_url,omitempty"` |
| +} |
| + |
| +func (s *Server) handleAPIGeo(w http.ResponseWriter, r *http.Request) { |
| + // The instance family's gate: the app's member, app and instance |
| + // tokens, and its per-caller budget of 60 a minute. |
| + if _, _, _, ok := s.instanceGateFor(w, r, "geo"); !ok { |
| + return |
| + } |
| + ip := net.ParseIP(r.URL.Query().Get("ip")) |
| + if ip == nil { |
| + jsonError(w, http.StatusBadRequest, "ip: want an IPv4 or IPv6 address") |
| + return |
| + } |
| + if s.cfg.GeoIPReader == nil { |
| + jsonError(w, http.StatusServiceUnavailable, "geo unavailable: this deployment has no GeoIP database") |
| + return |
| + } |
| + // One snapshot: the answer, its source and its attribution all come |
| + // from the same reader (spec §5.4). |
| + a := geoip.FromSnapshot(s.cfg.GeoIPReader.Lookup(ip)) |
| + switch a.State { |
| + case geoip.StateOff: |
| + jsonError(w, http.StatusServiceUnavailable, "geo unavailable: this deployment has no GeoIP database") |
| + return |
| + case geoip.StateMiss: |
| + writeJSON(w, http.StatusOK, geoResponse{Found: false}) |
| + return |
| + } |
| + out := geoResponse{Found: true, Country: a.Country, Subdivision: a.Subdivision, Source: a.Source} |
| + if a.HasPoint { |
| + lat, lon := a.Lat, a.Lon |
| + out.Lat, out.Lon = &lat, &lon |
| + } |
| + if at, ok := geoip.AttributionFor(a.Source); ok { |
| + out.Attribution, out.AttributionURL = at.Text, at.URL |
| + } |
| + writeJSON(w, http.StatusOK, out) |
| +} |
diff --git a/internal/console/apigeo_test.go b/internal/console/apigeo_test.go| new file mode 100644 |
| index 000000000..ccd584402 |
| --- /dev/null |
| +++ b/internal/console/apigeo_test.go |
| @@ -0,0 +1,58 @@ |
| +package console |
| + |
| +import ( |
| + "bytes" |
| + "encoding/json" |
| + "log" |
| + "net/http" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| +) |
| + |
| +func TestAPIGeo(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-geo-api") |
| + base := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/titogo/geo" |
| + if st, _, _ := rawReq(t, http.MethodGet, base+"?ip=81.2.69.142", token, ""); st != http.StatusServiceUnavailable { |
| + t.Fatalf("no reader = %d, want 503", st) |
| + } |
| + sw := geo.NewSwappable() |
| + fx.srv.cfg.GeoIPReader = sw |
| + if st, _, _ := rawReq(t, http.MethodGet, base+"?ip=81.2.69.142", token, ""); st != http.StatusServiceUnavailable { |
| + t.Fatalf("empty reader = %d, want 503", st) |
| + } |
| + db, err := geo.OpenMMDB("../geo/testdata/GeoIP2-City-Test.mmdb") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.Swap(db) |
| + var logs bytes.Buffer |
| + prev := log.Writer() |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(prev) |
| + st, _, body := rawReq(t, http.MethodGet, base+"?ip=81.2.69.142", token, "") |
| + if st != 200 { |
| + t.Fatalf("%d %s", st, body) |
| + } |
| + var got geoResponse |
| + json.Unmarshal([]byte(body), &got) |
| + if !got.Found || got.Country != "GB" || got.Subdivision != "GB-ENG" || got.Lat == nil || *got.Lat != 51.51 || got.Source != "GeoIP2-City" { |
| + t.Fatalf("answer = %+v", got) |
| + } |
| + if got.Attribution != "" { |
| + t.Fatal("GeoIP2 (commercial) has no known attribution; none must be invented") |
| + } |
| + if st, _, body := rawReq(t, http.MethodGet, base+"?ip=10.0.0.1", token, ""); st != 200 || !strings.Contains(body, `"found":false`) { |
| + t.Fatalf("private address = %d %s", st, body) |
| + } |
| + if st, _, _ := rawReq(t, http.MethodGet, base+"?ip=not-an-ip", token, ""); st != http.StatusBadRequest { |
| + t.Fatalf("bad ip = %d", st) |
| + } |
| + if st, _, _ := rawReq(t, http.MethodGet, base, token, ""); st != http.StatusBadRequest { |
| + t.Fatalf("missing ip = %d", st) |
| + } |
| + if strings.Contains(logs.String(), "81.2.69.142") { |
| + t.Fatalf("the queried address reached the log: %s", logs.String()) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 0215780ef..f7f92fd8e 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -617,6 +617,9 @@ func (s *Server) registerCLIRoutes() { |
| // own page views. The SETTING that decides whether those counts exist is |
| // the telemetry PUT below, and that stays Operate-only. |
| s.cliRoute("GET /api/cli/apps/{account}/{app}/analytics", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIAnalytics) |
| + // The platform's GeoIP answer for an arbitrary address (geoip spec |
| + // §11.1): the same holders as the analytics read. |
| + s.cliRoute("GET /api/cli/apps/{account}/{app}/geo", svcrole.Allow(svcrole.Instance, svcrole.Operate), s.handleAPIGeo) |
| // The telemetry SETTINGS write, Operate only and deliberately narrower |
| // than the read beside it: this decides what counts as an error for the |
| // app's owner, and a credential the app itself holds must not be able to |
diff --git a/internal/console/geoipfollower.go b/internal/console/geoipfollower.go| new file mode 100644 |
| index 000000000..0d03aed30 |
| --- /dev/null |
| +++ b/internal/console/geoipfollower.go |
| @@ -0,0 +1,93 @@ |
| +package console |
| + |
| +// The console's GeoIP reader (geoip spec §11.3): it follows the generation |
| +// this box's own edge mirrors, checking /data/carlos/geoip/current once a |
| +// minute, so region guesses and the /geo endpoint answer from the published |
| +// database without a second download or a restart. |
| + |
| +import ( |
| + "errors" |
| + "log" |
| + "os" |
| + "path/filepath" |
| + "sync" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/geoip" |
| +) |
| + |
| +type geoipFollower struct { |
| + dir string |
| + sw *geo.Swappable |
| + now func() time.Time |
| + |
| + mu sync.Mutex |
| + loadedSHA string |
| + lastErrLog time.Time |
| +} |
| + |
| +// NewGeoipFollower follows dir (the edge's local GeoIP directory) into sw. |
| +func NewGeoipFollower(dir string, sw *geo.Swappable) *geoipFollower { |
| + return &geoipFollower{dir: dir, sw: sw, now: time.Now} |
| +} |
| + |
| +// Tick is one check (spec §11.3's table). |
| +func (f *geoipFollower) Tick() { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + raw, err := os.ReadFile(filepath.Join(f.dir, geoip.LocalCurrentFile)) |
| + var cur geoip.LocalCurrent |
| + if err == nil { |
| + cur, err = geoip.ParseLocalCurrent(raw) |
| + } |
| + switch { |
| + case errors.Is(err, os.ErrNotExist) || (err == nil && cur.SHA256 == ""): |
| + f.drop("") // off, or nothing published yet: the normal empty start |
| + return |
| + case err != nil: |
| + f.logHourly("console: geoip local state unreadable; keeping the loaded database") |
| + return |
| + case geoip.Expired(cur.DatabaseType, time.Unix(cur.BuildEpoch, 0), f.now()): |
| + f.drop("console: geoip database expired (GeoLite2, 30 days after build) and was closed") |
| + return |
| + case cur.SHA256 == f.loadedSHA: |
| + return |
| + } |
| + db, err := geo.OpenMMDBTrusted(filepath.Join(f.dir, cur.SHA256+".mmdb")) |
| + if err != nil { |
| + // The edge may be mid-swap; the next tick sees the settled state. |
| + f.logHourly("console: geoip generation named by the edge is unopenable; keeping the loaded database") |
| + return |
| + } |
| + f.sw.Swap(db) |
| + f.loadedSHA = cur.SHA256 |
| + log.Printf("console: geoip database %s (%s) followed from this box's edge", cur.SHA256[:8], db.Source()) |
| +} |
| + |
| +func (f *geoipFollower) drop(msg string) { |
| + if f.loadedSHA == "" { |
| + return |
| + } |
| + f.sw.Swap(nil) |
| + f.loadedSHA = "" |
| + if msg != "" { |
| + log.Print(msg) |
| + } |
| +} |
| + |
| +func (f *geoipFollower) logHourly(msg string) { |
| + if now := f.now(); now.Sub(f.lastErrLog) >= time.Hour { |
| + f.lastErrLog = now |
| + log.Print(msg) |
| + } |
| +} |
| + |
| +// Run ticks once a minute, forever (process lifetime, like the console's |
| +// other loops started from cmd/console). |
| +func (f *geoipFollower) Run() { |
| + f.Tick() |
| + for range time.Tick(time.Minute) { |
| + f.Tick() |
| + } |
| +} |
diff --git a/internal/console/geoipfollower_test.go b/internal/console/geoipfollower_test.go| new file mode 100644 |
| index 000000000..b223cf8b7 |
| --- /dev/null |
| +++ b/internal/console/geoipfollower_test.go |
| @@ -0,0 +1,92 @@ |
| +package console |
| + |
| +import ( |
| + "net" |
| + "os" |
| + "path/filepath" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/geoip" |
| +) |
| + |
| +func writeLocal(t *testing.T, dir, name string, body []byte, typ string, build time.Time) string { |
| + t.Helper() |
| + os.MkdirAll(dir, 0o755) |
| + sha := name |
| + if err := os.WriteFile(filepath.Join(dir, sha+".mmdb"), body, 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + cur := `{"sha256":"` + sha + `","database_type":"` + typ + `","build_epoch":` + itoa(build.Unix()) + `}` |
| + if err := os.WriteFile(filepath.Join(dir, geoip.LocalCurrentFile), []byte(cur), 0o644); err != nil { |
| + t.Fatal(err) |
| + } |
| + return sha |
| +} |
| + |
| +func itoa(n int64) string { return strconv.FormatInt(n, 10) } |
| + |
| +func TestFollowerTracksTheEdgesGeneration(t *testing.T) { |
| + dir := filepath.Join(t.TempDir(), "geoip") |
| + sw := geo.NewSwappable() |
| + f := NewGeoipFollower(dir, sw) |
| + f.Tick() // nothing yet: the normal empty start |
| + if _, ok := sw.Locate(net.ParseIP("81.2.69.142")); ok { |
| + t.Fatal("located with nothing published") |
| + } |
| + shaA := strings.Repeat("a", 64) |
| + writeLocal(t, dir, shaA, fixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| + f.Tick() |
| + if snap := sw.Lookup(net.ParseIP("81.2.69.142")); !snap.Found || snap.Source != "GeoIP2-City" { |
| + t.Fatalf("after publish = %+v", snap) |
| + } |
| + // Off: the edge empties current. |
| + os.WriteFile(filepath.Join(dir, geoip.LocalCurrentFile), []byte(`{}`), 0o644) |
| + f.Tick() |
| + if sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| + t.Fatal("still loaded after off") |
| + } |
| + // current removed entirely: also off. |
| + writeLocal(t, dir, shaA, fixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| + f.Tick() |
| + os.Remove(filepath.Join(dir, geoip.LocalCurrentFile)) |
| + f.Tick() |
| + if sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| + t.Fatal("still loaded with current gone") |
| + } |
| +} |
| + |
| +func TestFollowerDropsAnExpiredGeoLite(t *testing.T) { |
| + dir := filepath.Join(t.TempDir(), "geoip") |
| + sw := geo.NewSwappable() |
| + f := NewGeoipFollower(dir, sw) |
| + sha := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" |
| + now := time.Now() |
| + writeLocal(t, dir, sha, fixture(t, "GeoIP2-City-Test.mmdb"), "GeoLite2-City", now) |
| + f.Tick() |
| + if !sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| + t.Fatal("not loaded") |
| + } |
| + f.now = func() time.Time { return now.Add(31 * 24 * time.Hour) } |
| + f.Tick() |
| + if sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| + t.Fatal("an expired GeoLite2 is still in use") |
| + } |
| +} |
| + |
| +func TestFollowerKeepsTheDatabaseOnAnUnreadableState(t *testing.T) { |
| + dir := filepath.Join(t.TempDir(), "geoip") |
| + sw := geo.NewSwappable() |
| + f := NewGeoipFollower(dir, sw) |
| + sha := "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" |
| + writeLocal(t, dir, sha, fixture(t, "GeoIP2-City-Test.mmdb"), "GeoIP2-City", time.Now()) |
| + f.Tick() |
| + os.WriteFile(filepath.Join(dir, geoip.LocalCurrentFile), []byte(`not json`), 0o644) |
| + f.Tick() |
| + if !sw.Lookup(net.ParseIP("81.2.69.142")).Loaded { |
| + t.Fatal("a garbled current dropped the database") |
| + } |
| +} |
diff --git a/internal/console/regionpicker.go b/internal/console/regionpicker.go| index fc3e1b7a3..722ab5fd8 100644 |
| --- a/internal/console/regionpicker.go |
| +++ b/internal/console/regionpicker.go |
| @@ -20,6 +20,10 @@ type regionPickerView struct { |
| Auto string // the @auto option's label (C3) |
| Options []regionOption |
| Attribution bool // C24, when the IP step is configured |
| + // AttributionText and AttributionURL are the credit the loaded |
| + // database requires (geoip spec §13): DB-IP's is C24, GeoLite2's is |
| + // MaxMind's. |
| + AttributionText, AttributionURL string |
| } |
| // newAppPicker builds the picker, or nil: switch off, an edges read |
| @@ -39,9 +43,13 @@ func (s *Server) newAppPicker(ctx context.Context, ip net.IP) *regionPickerView |
| return nil |
| } |
| g, _ := guessRegion(cands, func(string) bool { return true }, guessInput{IP: ip}, s.cfg.GeoIP) |
| - v := ®ionPickerView{ |
| - Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands)), |
| - Attribution: s.cfg.GeoIP != nil, |
| + v := ®ionPickerView{Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands))} |
| + switch a, ok := s.geoipAttribution(); { |
| + case ok: |
| + v.Attribution, v.AttributionText, v.AttributionURL = true, a.Text, a.URL |
| + case s.cfg.GeoIPReader == nil && s.cfg.GeoIP != nil: |
| + // A bare Locator (tests, older wiring): the IP step is DB-IP's. |
| + v.Attribution, v.AttributionText, v.AttributionURL = true, regionCopy.Attribution, regionCopy.AttributionURL |
| } |
| for _, c := range cands { |
| if c.Eligible { |
diff --git a/internal/console/server.go b/internal/console/server.go| index b747cd8f9..cbc851ebf 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -38,6 +38,7 @@ import ( |
| "github.com/carlosframework/platform/internal/economics" |
| "github.com/carlosframework/platform/internal/edgesfile" |
| "github.com/carlosframework/platform/internal/geo" |
| + "github.com/carlosframework/platform/internal/geoip" |
| "github.com/carlosframework/platform/internal/infraview" |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| @@ -497,6 +498,11 @@ type Config struct { |
| AppRegions bool |
| // GeoIP is the optional CONSOLE_GEOIP_DB locator; nil = IP step off. |
| GeoIP geo.Locator |
| + // GeoIPReader is the same reader as GeoIP when it is a Swappable (the |
| + // CONSOLE_GEOIP_DB override, or the follower of this box's edge, geoip |
| + // spec §11.3): the /geo endpoint and attribution need its snapshots, |
| + // which a bare Locator cannot give. nil = no GeoIP on this console. |
| + GeoIPReader *geo.Swappable |
| // GeoipStagingDir is where `carlos system geoip publish` uploads are |
| // staged (geoip spec §4.2): cleared when the server is constructed, so a |
| // crash never leaves an upload behind for good. "" = the OS temp dir, |
| @@ -4799,3 +4805,16 @@ func summarize(v AppView, acct string) appRow { |
| } |
| return row |
| } |
| + |
| +// geoipAttribution is the credit the loaded GeoIP database requires, or |
| +// false when none is loaded or its licence names none (geoip spec §13). |
| +func (s *Server) geoipAttribution() (geoip.Attribution, bool) { |
| + if s.cfg.GeoIPReader == nil { |
| + return geoip.Attribution{}, false |
| + } |
| + src, _, ok := s.cfg.GeoIPReader.Loaded() |
| + if !ok { |
| + return geoip.Attribution{}, false |
| + } |
| + return geoip.AttributionFor(src) |
| +} |
diff --git a/internal/console/templates/app_new.html b/internal/console/templates/app_new.html| index 879ecd4c9..efd6e13b1 100644 |
| --- a/internal/console/templates/app_new.html |
| +++ b/internal/console/templates/app_new.html |
| @@ -18,7 +18,7 @@ |
| </select> |
| <input type="hidden" name="tz" value=""> |
| <p class="small">{{$.RegionCopy.PickerHelp}}</p> |
| - {{if .Attribution}}<p class="small"><a href="{{$.RegionCopy.AttributionURL}}">{{$.RegionCopy.Attribution}}</a></p>{{end}} |
| + {{if .Attribution}}<p class="small"><a href="{{.AttributionURL}}">{{.AttributionText}}</a></p>{{end}} |
| <script> |
| // Enhancement only: with JS off the server's IP-or-home guess stands. |
| // Fills the tz hint and relabels ONLY the @auto option; its value stays |
diff --git a/internal/edge/geoipmirror.go b/internal/edge/geoipmirror.go| index 7cee66a56..58c4a1812 100644 |
| --- a/internal/edge/geoipmirror.go |
| +++ b/internal/edge/geoipmirror.go |
| @@ -37,11 +37,9 @@ var ( |
| geoExpiryInterval = time.Minute |
| ) |
| -type geoCurrent struct { |
| - SHA256 string `json:"sha256,omitempty"` |
| - DatabaseType string `json:"database_type,omitempty"` |
| - BuildEpoch int64 `json:"build_epoch,omitempty"` |
| -} |
| +// geoCurrent is the shared local format (geoip.LocalCurrent), so the |
| +// console's follower reads exactly what this mirror writes. |
| +type geoCurrent = geoip.LocalCurrent |
| type geoMirror struct { |
| store release.Store |
| @@ -137,17 +135,11 @@ func short(sha string) string { |
| func (m *geoMirror) readCurrent() (geoCurrent, error) { |
| var c geoCurrent |
| - b, err := os.ReadFile(filepath.Join(m.dir, "current")) |
| + b, err := os.ReadFile(filepath.Join(m.dir, geoip.LocalCurrentFile)) |
| if err != nil { |
| return c, err |
| } |
| - if err := json.Unmarshal(b, &c); err != nil { |
| - return c, err |
| - } |
| - if c.SHA256 != "" && !geoip.IsSHA256Hex(c.SHA256) { |
| - return geoCurrent{}, errors.New("bad sha") |
| - } |
| - return c, nil |
| + return geoip.ParseLocalCurrent(b) |
| } |
| // writeCurrentLocked replaces `current` atomically: temp, fsync, rename, |
diff --git a/internal/geoip/pointer.go b/internal/geoip/pointer.go| index 055915635..695ac5c8b 100644 |
| --- a/internal/geoip/pointer.go |
| +++ b/internal/geoip/pointer.go |
| @@ -112,3 +112,29 @@ func AttributionFor(dbType string) (Attribution, bool) { |
| } |
| return Attribution{}, false |
| } |
| + |
| +// LocalCurrentFile is the one mutable file in a box's local GeoIP directory |
| +// (spec §5.2): it names the selected generation, <sha256>.mmdb beside it. |
| +// The edge writes it; the console's follower (spec §11.3) reads it. |
| +const LocalCurrentFile = "current" |
| + |
| +// LocalCurrent is LocalCurrentFile's content. An empty SHA256 means no |
| +// database is selected. |
| +type LocalCurrent struct { |
| + SHA256 string `json:"sha256,omitempty"` |
| + DatabaseType string `json:"database_type,omitempty"` |
| + BuildEpoch int64 `json:"build_epoch,omitempty"` |
| +} |
| + |
| +// ParseLocalCurrent decodes LocalCurrentFile, refusing a sha that is not |
| +// 64 lower-case hex characters (it becomes a path). |
| +func ParseLocalCurrent(raw []byte) (LocalCurrent, error) { |
| + var c LocalCurrent |
| + if err := json.Unmarshal(raw, &c); err != nil { |
| + return LocalCurrent{}, err |
| + } |
| + if c.SHA256 != "" && !IsSHA256Hex(c.SHA256) { |
| + return LocalCurrent{}, errors.New("geoip local current: bad sha") |
| + } |
| + return c, nil |
| +} |
diff --git a/main.go b/main.go| index 16f2ccd5b..1e0ae5d37 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -93,6 +93,8 @@ func main() { |
| runErrors(args) |
| case "availability": |
| runAvailability(args) |
| + case "geo": |
| + runGeo(args) |
| case "analytics": |
| runAnalytics(args) |
| case "channels": |
| @@ -237,6 +239,7 @@ Your apps — ship and run software on a CARLOS deployment |
| grouped by cause, plus failed wakes and exits |
| analytics an app's page views and visitors from a terminal, counted at |
| the edge as it serves your app |
| + geo what the platform's GeoIP database says about an address |
| ledger open ledgers: append | head | list | publish | blob | verify |
| vet check a release against the platform contract |
| update update this carlos binary to the latest release (or name |