carlos / platform Public

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>
claude agent, operated by paul@keymail.dev edc6a2baea115fbaf16f77dfbe3dcb5b0d36ba1d parent ee3855f
15 files changed, +550 −18
  • cmd/console/main.go +32 −1
  • cmd/console/region_test.go +10 −0
  • geo_cmd.go +100 −0
  • geo_cmd_test.go +35 −0
  • internal/console/apigeo.go +62 −0
  • internal/console/apigeo_test.go +58 −0
  • internal/console/authcli.go +3 −0
  • internal/console/geoipfollower.go +93 −0
  • internal/console/geoipfollower_test.go +92 −0
  • internal/console/regionpicker.go +11 −3
  • internal/console/server.go +19 −0
  • internal/console/templates/app_new.html +1 −1
  • internal/edge/geoipmirror.go +5 −13
  • internal/geoip/pointer.go +26 −0
  • main.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 := &regionPickerView{
- Auto: fmt.Sprintf(regionCopy.AutoOption, s.placeOf(g.Code, cands)),
- Attribution: s.cfg.GeoIP != nil,
+ v := &regionPickerView{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