| 1 | // Package geoip is the platform's GeoIP vocabulary (spec |
| 2 | // docs/superpowers/specs/2026-10-06-platform-geoip-design.md): the bucket |
| 3 | // records the console writes and every edge reads, the GeoLite2 expiry rule |
| 4 | // (D1), attribution, and the request headers. No I/O lives here. |
| 5 | package geoip |
| 6 | |
| 7 | import ( |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "errors" |
| 11 | "fmt" |
| 12 | "strings" |
| 13 | "time" |
| 14 | ) |
| 15 | |
| 16 | const ( |
| 17 | Prefix = "control/geoip/" |
| 18 | PointerKey = Prefix + "current.json" |
| 19 | FeedKey = Prefix + "feed.json" |
| 20 | MaxBlobBytes = int64(1 << 30) |
| 21 | MaxPointerBytes = 64 << 10 |
| 22 | |
| 23 | geoLiteTTL = 30 * 24 * time.Hour |
| 24 | GeoLiteWarn = 21 * 24 * time.Hour |
| 25 | StaleAfter = 45 * 24 * time.Hour |
| 26 | ) |
| 27 | |
| 28 | // BlobKey is the content-addressed object for a database whose bytes hash |
| 29 | // to sha (lower-case hex). |
| 30 | func BlobKey(sha string) string { return Prefix + sha + ".mmdb" } |
| 31 | |
| 32 | // ErrUnknownVersion is a pointer this binary cannot read. Spec §3: treat it |
| 33 | // as no pointer (GeoIP off), never as "keep what is loaded". |
| 34 | var ErrUnknownVersion = errors.New("unknown geoip pointer version") |
| 35 | |
| 36 | // Pointer is control/geoip/current.json (spec §3). |
| 37 | type Pointer struct { |
| 38 | V int `json:"v"` |
| 39 | SHA256 string `json:"sha256"` |
| 40 | Key string `json:"key"` |
| 41 | Size int64 `json:"size"` |
| 42 | DatabaseType string `json:"database_type"` |
| 43 | BuildEpoch int64 `json:"build_epoch"` |
| 44 | NodeCount uint64 `json:"node_count"` |
| 45 | Published time.Time `json:"published"` |
| 46 | Origin string `json:"origin"` |
| 47 | Edition string `json:"edition"` |
| 48 | Expired bool `json:"expired,omitempty"` |
| 49 | } |
| 50 | |
| 51 | // Build is BuildEpoch as a time. |
| 52 | func (p Pointer) Build() time.Time { return time.Unix(p.BuildEpoch, 0).UTC() } |
| 53 | |
| 54 | // ParsePointer decodes and checks a pointer. An unknown version, a key that |
| 55 | // is not this sha's blob key, or an impossible size is an error: an edge |
| 56 | // never follows a pointer it cannot fully vouch for. |
| 57 | func ParsePointer(raw []byte) (Pointer, error) { |
| 58 | var p Pointer |
| 59 | if err := json.Unmarshal(raw, &p); err != nil { |
| 60 | return Pointer{}, fmt.Errorf("geoip pointer: %w", err) |
| 61 | } |
| 62 | if p.V != 1 { |
| 63 | return Pointer{}, fmt.Errorf("geoip pointer: version %d: %w", p.V, ErrUnknownVersion) |
| 64 | } |
| 65 | if !IsSHA256Hex(p.SHA256) { |
| 66 | return Pointer{}, errors.New("geoip pointer: sha256 is not 64 lower-case hex characters") |
| 67 | } |
| 68 | if p.Key != BlobKey(p.SHA256) { |
| 69 | return Pointer{}, errors.New("geoip pointer: key is not the sha's blob key") |
| 70 | } |
| 71 | if p.Size <= 0 || p.Size > MaxBlobBytes { |
| 72 | return Pointer{}, fmt.Errorf("geoip pointer: size %d out of range", p.Size) |
| 73 | } |
| 74 | return p, nil |
| 75 | } |
| 76 | |
| 77 | // IsSHA256Hex is 64 lower-case hex characters. |
| 78 | func IsSHA256Hex(s string) bool { |
| 79 | if len(s) != 64 || strings.ToLower(s) != s { |
| 80 | return false |
| 81 | } |
| 82 | _, err := hex.DecodeString(s) |
| 83 | return err == nil |
| 84 | } |
| 85 | |
| 86 | // ExpiresAt is D1: a GeoLite2 database expires 30 days after it was built. |
| 87 | // ok is false for every other type, which never expires. |
| 88 | func ExpiresAt(dbType string, build time.Time) (time.Time, bool) { |
| 89 | if !strings.HasPrefix(dbType, "GeoLite2-") { |
| 90 | return time.Time{}, false |
| 91 | } |
| 92 | return build.Add(geoLiteTTL), true |
| 93 | } |
| 94 | |
| 95 | // Expired reports whether now is at or past ExpiresAt. |
| 96 | func Expired(dbType string, build, now time.Time) bool { |
| 97 | at, ok := ExpiresAt(dbType, build) |
| 98 | return ok && !now.Before(at) |
| 99 | } |
| 100 | |
| 101 | // Attribution is the credit a page showing the data must carry (spec §13). |
| 102 | type Attribution struct{ Text, URL string } |
| 103 | |
| 104 | // AttributionFor names the attribution for a database type. ok is false |
| 105 | // when none is known: the operator's own licence governs. |
| 106 | func AttributionFor(dbType string) (Attribution, bool) { |
| 107 | switch { |
| 108 | case strings.HasPrefix(dbType, "DBIP-"): |
| 109 | return Attribution{"IP Geolocation by DB-IP", "https://db-ip.com"}, true |
| 110 | case strings.HasPrefix(dbType, "GeoLite2-"): |
| 111 | return Attribution{"This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com", "https://www.maxmind.com"}, true |
| 112 | } |
| 113 | return Attribution{}, false |
| 114 | } |
| 115 | |
| 116 | // LocalCurrentFile is the one mutable file in a box's local GeoIP directory |
| 117 | // (spec §5.2): it names the selected generation, <sha256>.mmdb beside it. |
| 118 | // The edge writes it; the console's follower (spec §11.3) reads it. |
| 119 | const LocalCurrentFile = "current" |
| 120 | |
| 121 | // LocalCurrent is LocalCurrentFile's content. An empty SHA256 means no |
| 122 | // database is selected. |
| 123 | type LocalCurrent struct { |
| 124 | SHA256 string `json:"sha256,omitempty"` |
| 125 | DatabaseType string `json:"database_type,omitempty"` |
| 126 | BuildEpoch int64 `json:"build_epoch,omitempty"` |
| 127 | } |
| 128 | |
| 129 | // ParseLocalCurrent decodes LocalCurrentFile, refusing a sha that is not |
| 130 | // 64 lower-case hex characters (it becomes a path). |
| 131 | func ParseLocalCurrent(raw []byte) (LocalCurrent, error) { |
| 132 | var c LocalCurrent |
| 133 | if err := json.Unmarshal(raw, &c); err != nil { |
| 134 | return LocalCurrent{}, err |
| 135 | } |
| 136 | if c.SHA256 != "" && !IsSHA256Hex(c.SHA256) { |
| 137 | return LocalCurrent{}, errors.New("geoip local current: bad sha") |
| 138 | } |
| 139 | return c, nil |
| 140 | } |
| 141 | |