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 } // geoPrefix is the reserved namespace: spec §8.1 strips EVERY header in // it, not just the five this version writes, so a client cannot hand an app // an X-Carlos-Geo-City (or any later name) that looks platform-set. const geoPrefix = "X-Carlos-Geo-" // StripAll deletes every X-Carlos-Geo-* header and X-Carlos-Hop-Geo, in // any case and with underscores in place of hyphens: CGI and WSGI turn // both into the same HTTP_X_CARLOS_GEO_* key, so X_Carlos_Geo_Country // would otherwise reach a Python app as the platform's own header (code // review R12-1). func StripAll(h http.Header) { for k := range h { if reserved(k) { delete(h, k) } } } // reserved reports whether a header name falls in the platform's geo // namespace once underscores are read as hyphens. func reserved(k string) bool { n := strings.ReplaceAll(k, "_", "-") return len(n) >= len(geoPrefix) && strings.EqualFold(n[:len(geoPrefix)], geoPrefix) || strings.EqualFold(n, 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 }