| 1 | package geo |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "log" |
| 7 | "math" |
| 8 | "net" |
| 9 | "strings" |
| 10 | "sync" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/oschwald/maxminddb-golang" |
| 14 | ) |
| 15 | |
| 16 | // Locator turns a client address into a point. A nil Locator, or a nil |
| 17 | // *MMDB, locates nothing: the IP step is optional (spec §6.2). |
| 18 | type Locator interface { |
| 19 | Locate(ip net.IP) (Point, bool) |
| 20 | } |
| 21 | |
| 22 | // MMDB is an operator-supplied MaxMind-format database. lookup is the |
| 23 | // reader's Lookup, a field so a test can prove which addresses ever reach |
| 24 | // it; source, build and nodes are copied from the metadata at open so a |
| 25 | // test fake can set them too. |
| 26 | type MMDB struct { |
| 27 | r *maxminddb.Reader |
| 28 | lookup func(ip net.IP, result any) error |
| 29 | source string |
| 30 | build time.Time |
| 31 | nodes uint64 |
| 32 | |
| 33 | closeHook func() // test seam: called by Close before the mapping is released |
| 34 | } |
| 35 | |
| 36 | // OpenMMDB opens and verifies the file. An error means "IP step off"; the |
| 37 | // console logs it once and starts anyway. |
| 38 | func OpenMMDB(path string) (*MMDB, error) { |
| 39 | return OpenMMDBVerified(context.Background(), path) |
| 40 | } |
| 41 | |
| 42 | // ErrSearchTreeTooLarge is a database whose search tree has far more paths |
| 43 | // than nodes: shared subtrees (a DAG) make the library's verifier, which |
| 44 | // enumerates every network, run for an exponential time. |
| 45 | var ErrSearchTreeTooLarge = errors.New("geo: the database's search tree has far more networks than nodes") |
| 46 | |
| 47 | // OpenMMDBVerified is OpenMMDB with the verification bounded (code review |
| 48 | // R8-1). maxminddb's Verify walks every network path with no visited set, |
| 49 | // so a crafted file whose nodes share children enumerates 2^depth paths |
| 50 | // and never returns. A pre-pass over the same walk, capped at a small |
| 51 | // multiple of the node count (a real tree has about one network per node, |
| 52 | // times a few for the IPv4 aliases) and checking ctx, refuses such a file |
| 53 | // in time before Verify runs. |
| 54 | func OpenMMDBVerified(ctx context.Context, path string) (*MMDB, error) { |
| 55 | r, err := maxminddb.Open(path) |
| 56 | if err != nil { |
| 57 | return nil, err |
| 58 | } |
| 59 | if err := boundedTreeWalk(ctx, r); err != nil { |
| 60 | r.Close() |
| 61 | return nil, err |
| 62 | } |
| 63 | if err := r.Verify(); err != nil { |
| 64 | r.Close() |
| 65 | return nil, err |
| 66 | } |
| 67 | return newMMDB(r), nil |
| 68 | } |
| 69 | |
| 70 | // CheckRecords decodes every network's record through the schema live |
| 71 | // lookups use (code review R11-1). maxminddb's Verify decodes records |
| 72 | // generically, but a lookup decodes into mmdbRecord, which skips unknown |
| 73 | // fields with a recursion one frame per value: a record carrying a huge |
| 74 | // ignored array passes Verify and then overflows a serving process's stack |
| 75 | // on the first lookup in its network. Run ONLY in the verifier child, under |
| 76 | // its memory cap and deadline: there such a record kills the child, which |
| 77 | // refuses the database. Aliased IPv4 networks are skipped (same records). |
| 78 | func (m *MMDB) CheckRecords(ctx context.Context) error { |
| 79 | if m == nil || m.r == nil { |
| 80 | return errors.New("geo: no database") |
| 81 | } |
| 82 | limit := 4*m.nodes + 1024 |
| 83 | it := m.r.Networks(maxminddb.SkipAliasedNetworks) |
| 84 | var n uint64 |
| 85 | for it.Next() { |
| 86 | var rec mmdbRecord |
| 87 | if _, err := it.Network(&rec); err != nil { |
| 88 | return err |
| 89 | } |
| 90 | n++ |
| 91 | if n > limit { |
| 92 | return ErrSearchTreeTooLarge |
| 93 | } |
| 94 | if n&0xffff == 0 { |
| 95 | if err := ctx.Err(); err != nil { |
| 96 | return err |
| 97 | } |
| 98 | } |
| 99 | } |
| 100 | return it.Err() |
| 101 | } |
| 102 | |
| 103 | func boundedTreeWalk(ctx context.Context, r *maxminddb.Reader) error { |
| 104 | limit := 4*uint64(r.Metadata.NodeCount) + 1024 |
| 105 | it := r.Networks() |
| 106 | var n uint64 |
| 107 | for it.Next() { |
| 108 | n++ |
| 109 | if n > limit { |
| 110 | return ErrSearchTreeTooLarge |
| 111 | } |
| 112 | if n&0xffff == 0 { |
| 113 | if err := ctx.Err(); err != nil { |
| 114 | return err |
| 115 | } |
| 116 | } |
| 117 | } |
| 118 | return it.Err() |
| 119 | } |
| 120 | |
| 121 | // OpenMMDBTrusted opens without Verify. Only for a file whose bytes were |
| 122 | // verified elsewhere and proven identical by digest (geoip spec §5.3): |
| 123 | // Verify decodes the whole data section, which costs seconds and tens of |
| 124 | // MiB on every edge for no new information. |
| 125 | func OpenMMDBTrusted(path string) (*MMDB, error) { |
| 126 | r, err := maxminddb.Open(path) |
| 127 | if err != nil { |
| 128 | return nil, err |
| 129 | } |
| 130 | return newMMDB(r), nil |
| 131 | } |
| 132 | |
| 133 | func newMMDB(r *maxminddb.Reader) *MMDB { |
| 134 | md := r.Metadata |
| 135 | return &MMDB{ |
| 136 | r: r, lookup: r.Lookup, |
| 137 | source: sanitizeSource(md.DatabaseType), |
| 138 | build: time.Unix(int64(md.BuildEpoch), 0).UTC(), |
| 139 | nodes: uint64(md.NodeCount), |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // sanitizeSource reduces a metadata database_type to [A-Za-z0-9._-], |
| 144 | // at most 64 bytes: it is echoed into a request header and a log line. |
| 145 | func sanitizeSource(s string) string { |
| 146 | var b strings.Builder |
| 147 | for _, c := range s { |
| 148 | if b.Len() >= 64 { |
| 149 | break |
| 150 | } |
| 151 | if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' { |
| 152 | b.WriteRune(c) |
| 153 | } |
| 154 | } |
| 155 | if b.Len() == 0 { |
| 156 | // Never empty (codex R7-2): a loaded reader's source must survive |
| 157 | // the hop header's grammar, or a local hit becomes "off" downstream. |
| 158 | return "mmdb" |
| 159 | } |
| 160 | return b.String() |
| 161 | } |
| 162 | |
| 163 | // Source is the database's type ("DBIP-City-Lite", "GeoLite2-City"), sanitised. |
| 164 | func (m *MMDB) Source() string { |
| 165 | if m == nil { |
| 166 | return "" |
| 167 | } |
| 168 | return m.source |
| 169 | } |
| 170 | |
| 171 | // BuildEpoch is when the database was built (UTC); zero for a nil MMDB. |
| 172 | func (m *MMDB) BuildEpoch() time.Time { |
| 173 | if m == nil { |
| 174 | return time.Time{} |
| 175 | } |
| 176 | return m.build |
| 177 | } |
| 178 | |
| 179 | // NodeCount is the search tree's size, the shrink check's yardstick. |
| 180 | func (m *MMDB) NodeCount() uint64 { |
| 181 | if m == nil { |
| 182 | return 0 |
| 183 | } |
| 184 | return m.nodes |
| 185 | } |
| 186 | |
| 187 | type mmdbRecord struct { |
| 188 | Location struct { |
| 189 | Latitude *float64 `maxminddb:"latitude"` |
| 190 | Longitude *float64 `maxminddb:"longitude"` |
| 191 | } `maxminddb:"location"` |
| 192 | Country struct { |
| 193 | ISOCode string `maxminddb:"iso_code"` |
| 194 | } `maxminddb:"country"` |
| 195 | RegisteredCountry struct { |
| 196 | ISOCode string `maxminddb:"iso_code"` |
| 197 | } `maxminddb:"registered_country"` |
| 198 | Subdivisions []struct { |
| 199 | ISOCode string `maxminddb:"iso_code"` |
| 200 | } `maxminddb:"subdivisions"` |
| 201 | } |
| 202 | |
| 203 | var ( |
| 204 | lookupErrMu sync.Mutex |
| 205 | lookupErrLast time.Time |
| 206 | ) |
| 207 | |
| 208 | // decode is the one place a record is read and judged (codex R1-9): the |
| 209 | // Locatable filter, IPv4 normalisation, the fixed-category error log, and |
| 210 | // the rule that a malformed location makes the WHOLE record a miss. |
| 211 | func (m *MMDB) decode(ip net.IP) (mmdbRecord, bool) { |
| 212 | var rec mmdbRecord |
| 213 | if m == nil || m.lookup == nil || !Locatable(ip) { |
| 214 | return rec, false |
| 215 | } |
| 216 | if v4 := ip.To4(); v4 != nil { |
| 217 | ip = v4 |
| 218 | } |
| 219 | if err := m.lookup(ip, &rec); err != nil { |
| 220 | lookupErrMu.Lock() |
| 221 | if time.Since(lookupErrLast) > time.Minute { |
| 222 | lookupErrLast = time.Now() |
| 223 | log.Print("geo: a GeoIP lookup failed and was treated as a miss") |
| 224 | } |
| 225 | lookupErrMu.Unlock() |
| 226 | return mmdbRecord{}, false |
| 227 | } |
| 228 | if rec.Location.Latitude != nil && rec.Location.Longitude != nil && |
| 229 | !onTheGlobe(Point{*rec.Location.Latitude, *rec.Location.Longitude}) { |
| 230 | // A structurally valid database can still carry a malformed |
| 231 | // location (final review). A record wrong in one field is not |
| 232 | // trusted on the next. |
| 233 | return mmdbRecord{}, false |
| 234 | } |
| 235 | return rec, true |
| 236 | } |
| 237 | |
| 238 | // Locate returns the record's location (City databases), else its |
| 239 | // country's point, else its registered country's. Behaviour is exactly |
| 240 | // what it was before Lookup existed (codex R1-9). |
| 241 | func (m *MMDB) Locate(ip net.IP) (Point, bool) { |
| 242 | rec, ok := m.decode(ip) |
| 243 | if !ok { |
| 244 | return Point{}, false |
| 245 | } |
| 246 | if rec.Location.Latitude != nil && rec.Location.Longitude != nil { |
| 247 | return Point{*rec.Location.Latitude, *rec.Location.Longitude}, true |
| 248 | } |
| 249 | if p, ok := CountryPoint(rec.Country.ISOCode); ok { |
| 250 | return p, true |
| 251 | } |
| 252 | return CountryPoint(rec.RegisteredCountry.ISOCode) |
| 253 | } |
| 254 | |
| 255 | // Result is what Lookup found. Country is ISO 3166-1 alpha-2 (the |
| 256 | // record's country, else its registered country); Subdivision is |
| 257 | // "CC-XXX" only when the database carries an ISO code for it (GeoLite2 |
| 258 | // does, DB-IP Lite does not, FACTS.md); HasPoint is true only for a real |
| 259 | // location record, never a country centroid. |
| 260 | type Result struct { |
| 261 | Country string |
| 262 | Subdivision string |
| 263 | Point Point |
| 264 | HasPoint bool |
| 265 | } |
| 266 | |
| 267 | // Lookup answers with whatever the record holds. ok is true when it found |
| 268 | // a country, a point, or both (geoip spec §6). |
| 269 | func (m *MMDB) Lookup(ip net.IP) (Result, bool) { |
| 270 | rec, ok := m.decode(ip) |
| 271 | if !ok { |
| 272 | return Result{}, false |
| 273 | } |
| 274 | var r Result |
| 275 | if isISO2(rec.Country.ISOCode) { |
| 276 | r.Country = rec.Country.ISOCode |
| 277 | if len(rec.Subdivisions) > 0 && isSubdivisionCode(rec.Subdivisions[0].ISOCode) { |
| 278 | r.Subdivision = r.Country + "-" + rec.Subdivisions[0].ISOCode |
| 279 | } |
| 280 | } else if isISO2(rec.RegisteredCountry.ISOCode) { |
| 281 | // The registered country says where the block is registered, not |
| 282 | // which subdivision of it the visitor is in: no subdivision. |
| 283 | r.Country = rec.RegisteredCountry.ISOCode |
| 284 | } |
| 285 | if rec.Location.Latitude != nil && rec.Location.Longitude != nil { |
| 286 | r.Point, r.HasPoint = Point{*rec.Location.Latitude, *rec.Location.Longitude}, true |
| 287 | } |
| 288 | return r, r.Country != "" || r.HasPoint |
| 289 | } |
| 290 | |
| 291 | func isISO2(s string) bool { |
| 292 | return len(s) == 2 && s[0] >= 'A' && s[0] <= 'Z' && s[1] >= 'A' && s[1] <= 'Z' |
| 293 | } |
| 294 | |
| 295 | func isSubdivisionCode(s string) bool { |
| 296 | if len(s) < 1 || len(s) > 3 { |
| 297 | return false |
| 298 | } |
| 299 | for i := 0; i < len(s); i++ { |
| 300 | if !(s[i] >= 'A' && s[i] <= 'Z' || s[i] >= '0' && s[i] <= '9') { |
| 301 | return false |
| 302 | } |
| 303 | } |
| 304 | return true |
| 305 | } |
| 306 | |
| 307 | // Close releases the mapping. |
| 308 | func (m *MMDB) Close() error { |
| 309 | if m == nil { |
| 310 | return nil |
| 311 | } |
| 312 | if m.closeHook != nil { |
| 313 | m.closeHook() |
| 314 | } |
| 315 | if m.r == nil { |
| 316 | return nil |
| 317 | } |
| 318 | return m.r.Close() |
| 319 | } |
| 320 | |
| 321 | // onTheGlobe is false for nonfinite coordinates, a latitude outside |
| 322 | // [-90, 90] or a longitude outside [-180, 180]. Both bounds are inclusive: |
| 323 | // the poles and the antimeridian are places. |
| 324 | func onTheGlobe(p Point) bool { |
| 325 | finite := func(f float64) bool { return !math.IsNaN(f) && !math.IsInf(f, 0) } |
| 326 | return finite(p.Lat) && finite(p.Lon) && p.Lat >= -90 && p.Lat <= 90 && p.Lon >= -180 && p.Lon <= 180 |
| 327 | } |
| 328 | |
| 329 | var cgnat = &net.IPNet{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)} |
| 330 | |
| 331 | // Locatable is false for addresses that say nothing about where a person |
| 332 | // is: loopback, private, link-local, CGNAT and unspecified. |
| 333 | func Locatable(ip net.IP) bool { |
| 334 | if ip == nil || ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() || |
| 335 | ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() || cgnat.Contains(ip) { |
| 336 | return false |
| 337 | } |
| 338 | return true |
| 339 | } |
| 340 | |