carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package geo
2
3import (
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).
18type 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.
26type 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.
38func 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.
45var 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.
54func 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).
78func (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
103func 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.
125func 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
133func 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.
145func 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.
164func (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.
172func (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.
180func (m *MMDB) NodeCount() uint64 {
181 if m == nil {
182 return 0
183 }
184 return m.nodes
185}
186
187type 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
203var (
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.
211func (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).
241func (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.
260type 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).
269func (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
291func isISO2(s string) bool {
292 return len(s) == 2 && s[0] >= 'A' && s[0] <= 'Z' && s[1] >= 'A' && s[1] <= 'Z'
293}
294
295func 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.
308func (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.
324func 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
329var 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.
333func 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