geoip: pointer, D1 expiry, attribution and the header vocabulary (geoip spec §3, §7, §8.1a)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+435
−0
internal/geoip/answer.go+173 −0internal/geoip/answer_test.go+81 −0internal/geoip/pointer.go+110 −0internal/geoip/pointer_test.go+71 −0
CI cancelled — superseded by a newer push — run details
diff --git a/internal/geoip/answer.go b/internal/geoip/answer.go| new file mode 100644 |
| index 000000000..cafe12c96 |
| --- /dev/null |
| +++ b/internal/geoip/answer.go |
| @@ -0,0 +1,173 @@ |
| +package geoip |
| + |
| +import ( |
| + "math" |
| + "net/http" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| +) |
| + |
| +const ( |
| + HeaderCountry = "X-Carlos-Geo-Country" |
| + HeaderSubdivision = "X-Carlos-Geo-Subdivision" |
| + HeaderLat = "X-Carlos-Geo-Lat" |
| + HeaderLon = "X-Carlos-Geo-Lon" |
| + HeaderSource = "X-Carlos-Geo-Source" |
| + // HeaderHop is edge-to-edge only (spec §8.1a): never written to a |
| + // request an app receives. |
| + HeaderHop = "X-Carlos-Hop-Geo" |
| +) |
| + |
| +var appHeaders = []string{HeaderCountry, HeaderSubdivision, HeaderLat, HeaderLon, HeaderSource} |
| + |
| +// State is what the ingress edge did: hit (a reader answered), miss (a |
| +// reader was loaded and found nothing, or the address is not locatable), |
| +// off (no lookup was attempted). |
| +type State string |
| + |
| +const ( |
| + StateHit State = "hit" |
| + StateMiss State = "miss" |
| + StateOff State = "off" |
| +) |
| + |
| +// Answer is one request's geo result, computed once at ingress or adopted |
| +// from a trusted transport. Lat/Lon are already rounded to 2 places. |
| +type Answer struct { |
| + State State |
| + Country string |
| + Subdivision string |
| + Source string |
| + Lat, Lon float64 |
| + HasPoint bool |
| +} |
| + |
| +// Off is the answer for a request no lookup was attempted for. |
| +func Off() Answer { return Answer{State: StateOff} } |
| + |
| +func round2(v float64) float64 { return math.Round(v*100) / 100 } |
| + |
| +// FromSnapshot turns one reader's answer into a request's answer. |
| +func FromSnapshot(s geo.Snapshot) Answer { |
| + switch { |
| + case !s.Loaded: |
| + return Off() |
| + case !s.Found: |
| + return Answer{State: StateMiss, Source: s.Source} |
| + } |
| + a := Answer{State: StateHit, Country: s.Result.Country, Subdivision: s.Result.Subdivision, Source: s.Source} |
| + if s.Result.HasPoint { |
| + a.Lat, a.Lon, a.HasPoint = round2(s.Result.Point.Lat), round2(s.Result.Point.Lon), true |
| + } |
| + return a |
| +} |
| + |
| +// StripAll deletes every geo header an app or an edge could receive. |
| +func StripAll(h http.Header) { |
| + for _, k := range appHeaders { |
| + h.Del(k) |
| + } |
| + h.Del(HeaderHop) |
| +} |
| + |
| +// WriteApp deletes the app headers and writes this answer's. A miss and |
| +// off write nothing at all; Source is written only beside another field. |
| +func (a Answer) WriteApp(h http.Header) { |
| + for _, k := range appHeaders { |
| + h.Del(k) |
| + } |
| + if a.State != StateHit { |
| + return |
| + } |
| + wrote := false |
| + if a.Country != "" { |
| + h.Set(HeaderCountry, a.Country) |
| + wrote = true |
| + if a.Subdivision != "" { |
| + h.Set(HeaderSubdivision, a.Subdivision) |
| + } |
| + } |
| + if a.HasPoint { |
| + h.Set(HeaderLat, strconv.FormatFloat(a.Lat, 'f', 2, 64)) |
| + h.Set(HeaderLon, strconv.FormatFloat(a.Lon, 'f', 2, 64)) |
| + wrote = true |
| + } |
| + if wrote && a.Source != "" { |
| + h.Set(HeaderSource, a.Source) |
| + } |
| +} |
| + |
| +// HopValue is X-Carlos-Hop-Geo for this answer. |
| +func (a Answer) HopValue() string { |
| + if a.State == StateOff || !sourceRE.MatchString(a.Source) { |
| + return string(StateOff) |
| + } |
| + return string(a.State) + ";src=" + a.Source |
| +} |
| + |
| +// Captured is what a request arrived carrying, before anyone trusts it. |
| +type Captured struct { |
| + Hop, Country, Subdivision, Lat, Lon, Source string |
| +} |
| + |
| +// Capture copies the geo headers out of h and strips them all (spec §8.1 |
| +// step 1). Nothing reads the copy until the transport is authenticated. |
| +func Capture(h http.Header) Captured { |
| + c := Captured{ |
| + Hop: h.Get(HeaderHop), Country: h.Get(HeaderCountry), Subdivision: h.Get(HeaderSubdivision), |
| + Lat: h.Get(HeaderLat), Lon: h.Get(HeaderLon), Source: h.Get(HeaderSource), |
| + } |
| + StripAll(h) |
| + return c |
| +} |
| + |
| +var ( |
| + countryRE = regexp.MustCompile(`^[A-Z]{2}$`) |
| + subdivisionRE = regexp.MustCompile(`^[A-Z]{2}-[A-Z0-9]{1,3}$`) |
| + sourceRE = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`) |
| +) |
| + |
| +// Adopt validates what a TRUSTED transport carried (spec §8.4). Call it only |
| +// for a verified hop or a TLS tunnel. Invalid fields are dropped one by one; |
| +// a hit left with neither a country nor a coordinate pair, or a missing or |
| +// malformed hop value, is off. |
| +func (c Captured) Adopt() Answer { |
| + state, src, ok := strings.Cut(c.Hop, ";src=") |
| + switch { |
| + case c.Hop == string(StateOff): |
| + return Off() |
| + case !ok || !sourceRE.MatchString(src): |
| + return Off() |
| + case State(state) == StateMiss: |
| + return Answer{State: StateMiss, Source: src} |
| + case State(state) != StateHit: |
| + return Off() |
| + } |
| + a := Answer{State: StateHit, Source: src} |
| + if countryRE.MatchString(c.Country) { |
| + a.Country = c.Country |
| + if subdivisionRE.MatchString(c.Subdivision) && strings.HasPrefix(c.Subdivision, c.Country+"-") { |
| + a.Subdivision = c.Subdivision |
| + } |
| + } |
| + if lat, lon, ok := parsePair(c.Lat, c.Lon); ok { |
| + a.Lat, a.Lon, a.HasPoint = lat, lon, true |
| + } |
| + if a.Country == "" && !a.HasPoint { |
| + return Off() |
| + } |
| + return a |
| +} |
| + |
| +func parsePair(latS, lonS string) (float64, float64, bool) { |
| + lat, err1 := strconv.ParseFloat(latS, 64) |
| + lon, err2 := strconv.ParseFloat(lonS, 64) |
| + if err1 != nil || err2 != nil || math.IsNaN(lat) || math.IsNaN(lon) || math.IsInf(lat, 0) || math.IsInf(lon, 0) || |
| + lat < -90 || lat > 90 || lon < -180 || lon > 180 { |
| + return 0, 0, false |
| + } |
| + return round2(lat), round2(lon), true |
| +} |
diff --git a/internal/geoip/answer_test.go b/internal/geoip/answer_test.go| new file mode 100644 |
| index 000000000..ce7855d65 |
| --- /dev/null |
| +++ b/internal/geoip/answer_test.go |
| @@ -0,0 +1,81 @@ |
| +package geoip |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/geo" |
| +) |
| + |
| +func TestFromSnapshotStates(t *testing.T) { |
| + if a := FromSnapshot(geo.Snapshot{}); a.State != StateOff { |
| + t.Fatalf("not loaded = %+v, want off", a) |
| + } |
| + if a := FromSnapshot(geo.Snapshot{Loaded: true, Source: "DBIP-City-Lite"}); a.State != StateMiss || a.Source != "DBIP-City-Lite" { |
| + t.Fatalf("loaded miss = %+v", a) |
| + } |
| + hit := FromSnapshot(geo.Snapshot{Loaded: true, Found: true, Source: "GeoLite2-City", |
| + Result: geo.Result{Country: "GB", Subdivision: "GB-ENG", Point: geo.Point{Lat: 51.5074, Lon: -0.1278}, HasPoint: true}}) |
| + if hit.State != StateHit || hit.Lat != 51.51 || hit.Lon != -0.13 { |
| + t.Fatalf("hit = %+v (coordinates must be rounded to 2 places)", hit) |
| + } |
| +} |
| + |
| +func TestWriteAppHeaders(t *testing.T) { |
| + h := http.Header{} |
| + h.Set(HeaderCountry, "XX") // stale value must go |
| + Answer{State: StateHit, Country: "IE", Source: "DBIP-City-Lite", Lat: 53.34, Lon: -6.27, HasPoint: true}.WriteApp(h) |
| + if h.Get(HeaderCountry) != "IE" || h.Get(HeaderLat) != "53.34" || h.Get(HeaderLon) != "-6.27" || |
| + h.Get(HeaderSource) != "DBIP-City-Lite" || h.Get(HeaderSubdivision) != "" || h.Get(HeaderHop) != "" { |
| + t.Fatalf("headers = %v", h) |
| + } |
| + h2 := http.Header{} |
| + h2.Set(HeaderSource, "forged") |
| + Answer{State: StateMiss, Source: "DBIP-City-Lite"}.WriteApp(h2) |
| + if len(h2) != 0 { |
| + t.Fatalf("a miss sends no app headers, not even Source: %v", h2) |
| + } |
| +} |
| + |
| +func TestHopValueRoundTrip(t *testing.T) { |
| + for _, a := range []Answer{ |
| + {State: StateHit, Country: "GB", Subdivision: "GB-ENG", Source: "GeoLite2-City", Lat: 51.51, Lon: -0.09, HasPoint: true}, |
| + {State: StateHit, Source: "GeoLite2-City", Lat: 10, Lon: 20, HasPoint: true}, // coordinate-only (codex R3-3) |
| + {State: StateMiss, Source: "DBIP-City-Lite"}, |
| + Off(), |
| + } { |
| + h := http.Header{} |
| + a.WriteApp(h) |
| + h.Set(HeaderHop, a.HopValue()) |
| + got := Capture(h).Adopt() |
| + if got != a { |
| + t.Errorf("round trip %+v -> %+v", a, got) |
| + } |
| + if len(h) != 0 { |
| + t.Errorf("Capture must strip every geo header, left %v", h) |
| + } |
| + } |
| +} |
| + |
| +func TestAdoptRefusesWhatItCannotTrust(t *testing.T) { |
| + cases := map[string]http.Header{ |
| + "no hop value": {HeaderCountry: {"GB"}, HeaderSource: {"x"}}, |
| + "malformed hop value": {HeaderHop: {"maybe"}}, |
| + "hit with nothing": {HeaderHop: {"hit;src=x"}}, |
| + "hit with bad country": {HeaderHop: {"hit;src=x"}, HeaderCountry: {"gb"}}, |
| + "bad source": {HeaderHop: {"hit;src=a b"}, HeaderCountry: {"GB"}}, |
| + } |
| + for name, h := range cases { |
| + if got := Capture(h).Adopt(); got.State != StateOff { |
| + t.Errorf("%s: adopted %+v, want off", name, got) |
| + } |
| + } |
| + // Field-level: an invalid subdivision or half a coordinate pair is |
| + // dropped on its own, the rest survives. |
| + h := http.Header{HeaderHop: {"hit;src=GeoLite2-City"}, HeaderCountry: {"GB"}, |
| + HeaderSubdivision: {"FR-IDF"}, HeaderLat: {"91"}, HeaderLon: {"0"}} |
| + got := Capture(h).Adopt() |
| + if got.State != StateHit || got.Country != "GB" || got.Subdivision != "" || got.HasPoint { |
| + t.Fatalf("partial = %+v", got) |
| + } |
| +} |
diff --git a/internal/geoip/pointer.go b/internal/geoip/pointer.go| new file mode 100644 |
| index 000000000..3cb5cb032 |
| --- /dev/null |
| +++ b/internal/geoip/pointer.go |
| @@ -0,0 +1,110 @@ |
| +// Package geoip is the platform's GeoIP vocabulary (spec |
| +// docs/superpowers/specs/2026-10-06-platform-geoip-design.md): the bucket |
| +// records the console writes and every edge reads, the GeoLite2 expiry rule |
| +// (D1), attribution, and the request headers. No I/O lives here. |
| +package geoip |
| + |
| +import ( |
| + "encoding/hex" |
| + "encoding/json" |
| + "errors" |
| + "fmt" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const ( |
| + Prefix = "control/geoip/" |
| + PointerKey = Prefix + "current.json" |
| + FeedKey = Prefix + "feed.json" |
| + MaxBlobBytes = int64(1 << 30) |
| + MaxPointerBytes = 64 << 10 |
| + |
| + geoLiteTTL = 30 * 24 * time.Hour |
| + GeoLiteWarn = 21 * 24 * time.Hour |
| + StaleAfter = 45 * 24 * time.Hour |
| +) |
| + |
| +// BlobKey is the content-addressed object for a database whose bytes hash |
| +// to sha (lower-case hex). |
| +func BlobKey(sha string) string { return Prefix + sha + ".mmdb" } |
| + |
| +// Pointer is control/geoip/current.json (spec §3). |
| +type Pointer struct { |
| + V int `json:"v"` |
| + SHA256 string `json:"sha256"` |
| + Key string `json:"key"` |
| + Size int64 `json:"size"` |
| + DatabaseType string `json:"database_type"` |
| + BuildEpoch int64 `json:"build_epoch"` |
| + NodeCount uint64 `json:"node_count"` |
| + Published time.Time `json:"published"` |
| + Origin string `json:"origin"` |
| + Edition string `json:"edition"` |
| + Expired bool `json:"expired,omitempty"` |
| +} |
| + |
| +// Build is BuildEpoch as a time. |
| +func (p Pointer) Build() time.Time { return time.Unix(p.BuildEpoch, 0).UTC() } |
| + |
| +// ParsePointer decodes and checks a pointer. An unknown version, a key that |
| +// is not this sha's blob key, or an impossible size is an error: an edge |
| +// never follows a pointer it cannot fully vouch for. |
| +func ParsePointer(raw []byte) (Pointer, error) { |
| + var p Pointer |
| + if err := json.Unmarshal(raw, &p); err != nil { |
| + return Pointer{}, fmt.Errorf("geoip pointer: %w", err) |
| + } |
| + if p.V != 1 { |
| + return Pointer{}, fmt.Errorf("geoip pointer: unknown version %d", p.V) |
| + } |
| + if !IsSHA256Hex(p.SHA256) { |
| + return Pointer{}, errors.New("geoip pointer: sha256 is not 64 lower-case hex characters") |
| + } |
| + if p.Key != BlobKey(p.SHA256) { |
| + return Pointer{}, errors.New("geoip pointer: key is not the sha's blob key") |
| + } |
| + if p.Size <= 0 || p.Size > MaxBlobBytes { |
| + return Pointer{}, fmt.Errorf("geoip pointer: size %d out of range", p.Size) |
| + } |
| + return p, nil |
| +} |
| + |
| +// IsSHA256Hex is 64 lower-case hex characters. |
| +func IsSHA256Hex(s string) bool { |
| + if len(s) != 64 || strings.ToLower(s) != s { |
| + return false |
| + } |
| + _, err := hex.DecodeString(s) |
| + return err == nil |
| +} |
| + |
| +// ExpiresAt is D1: a GeoLite2 database expires 30 days after it was built. |
| +// ok is false for every other type, which never expires. |
| +func ExpiresAt(dbType string, build time.Time) (time.Time, bool) { |
| + if !strings.HasPrefix(dbType, "GeoLite2-") { |
| + return time.Time{}, false |
| + } |
| + return build.Add(geoLiteTTL), true |
| +} |
| + |
| +// Expired reports whether now is at or past ExpiresAt. |
| +func Expired(dbType string, build, now time.Time) bool { |
| + at, ok := ExpiresAt(dbType, build) |
| + return ok && !now.Before(at) |
| +} |
| + |
| +// Attribution is the credit a page showing the data must carry (spec §13). |
| +type Attribution struct{ Text, URL string } |
| + |
| +// AttributionFor names the attribution for a database type. ok is false |
| +// when none is known: the operator's own licence governs. |
| +func AttributionFor(dbType string) (Attribution, bool) { |
| + switch { |
| + case strings.HasPrefix(dbType, "DBIP-"): |
| + return Attribution{"IP Geolocation by DB-IP", "https://db-ip.com"}, true |
| + case strings.HasPrefix(dbType, "GeoLite2-"): |
| + return Attribution{"This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com", "https://www.maxmind.com"}, true |
| + } |
| + return Attribution{}, false |
| +} |
diff --git a/internal/geoip/pointer_test.go b/internal/geoip/pointer_test.go| new file mode 100644 |
| index 000000000..6d515fb57 |
| --- /dev/null |
| +++ b/internal/geoip/pointer_test.go |
| @@ -0,0 +1,71 @@ |
| +package geoip |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +const sha = "9f2c000000000000000000000000000000000000000000000000000000000001" |
| + |
| +func TestParsePointerAcceptsTheSpecShape(t *testing.T) { |
| + raw := `{"v":1,"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":10, |
| + "database_type":"DBIP-City-Lite","build_epoch":1790812800,"node_count":7, |
| + "published":"2026-10-06T21:00:00Z","origin":"manual","edition":""}` |
| + p, err := ParsePointer([]byte(raw)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if p.Key != BlobKey(sha) || p.Size != 10 || p.DatabaseType != "DBIP-City-Lite" || p.Build().Unix() != 1790812800 { |
| + t.Fatalf("pointer = %+v", p) |
| + } |
| +} |
| + |
| +func TestParsePointerRefusesWhatAnEdgeMustNotFollow(t *testing.T) { |
| + good := `"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":10,"database_type":"X","build_epoch":1` |
| + bad := map[string]string{ |
| + "unknown version": `{"v":2,` + good + `}`, |
| + "short sha": `{"v":1,"sha256":"9f","key":"control/geoip/9f.mmdb","size":1,"database_type":"X","build_epoch":1}`, |
| + "key not the blob key": `{"v":1,"sha256":"` + sha + `","key":"apps/x/secret","size":10,"database_type":"X","build_epoch":1}`, |
| + "zero size": `{"v":1,"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":0,"database_type":"X","build_epoch":1}`, |
| + "oversize": `{"v":1,"sha256":"` + sha + `","key":"control/geoip/` + sha + `.mmdb","size":1073741825,"database_type":"X","build_epoch":1}`, |
| + "not json": `nope`, |
| + } |
| + for name, raw := range bad { |
| + if _, err := ParsePointer([]byte(raw)); err == nil { |
| + t.Errorf("%s: accepted", name) |
| + } |
| + } |
| +} |
| + |
| +func TestGeoLiteExpiresThirtyDaysAfterBuild(t *testing.T) { |
| + build := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC) |
| + at, ok := ExpiresAt("GeoLite2-City", build) |
| + if !ok || !at.Equal(build.Add(30*24*time.Hour)) { |
| + t.Fatalf("ExpiresAt = (%v, %v)", at, ok) |
| + } |
| + if Expired("GeoLite2-City", build, build.Add(30*24*time.Hour-time.Second)) { |
| + t.Fatal("expired a second early") |
| + } |
| + if !Expired("GeoLite2-Country", build, build.Add(30*24*time.Hour)) { |
| + t.Fatal("not expired at 30 days") |
| + } |
| + if Expired("DBIP-City-Lite", build, build.Add(400*24*time.Hour)) { |
| + t.Fatal("DB-IP must never expire (D1)") |
| + } |
| + if _, ok := ExpiresAt("GeoIP2-City", build); ok { |
| + t.Fatal("only GeoLite2- expires") |
| + } |
| +} |
| + |
| +func TestAttributionTable(t *testing.T) { |
| + if a, ok := AttributionFor("DBIP-City-Lite"); !ok || a.Text != "IP Geolocation by DB-IP" || a.URL != "https://db-ip.com" { |
| + t.Fatalf("dbip = %+v %v", a, ok) |
| + } |
| + if a, ok := AttributionFor("GeoLite2-Country"); !ok || !strings.Contains(a.Text, "GeoLite2 data created by MaxMind") { |
| + t.Fatalf("geolite = %+v %v", a, ok) |
| + } |
| + if _, ok := AttributionFor("GeoIP2-City"); ok { |
| + t.Fatal("no attribution is known for commercial GeoIP2; the operator's licence governs") |
| + } |
| +} |