// Package identity discovers the probe's public network identity (IP, ASN, org, country, city, city-level // coordinates) from ipinfo.io, falling back to ip-api.com. Nothing more precise than city is ever kept. package identity import ( "context" "encoding/json" "errors" "fmt" "io" "math" "net/http" "strconv" "strings" "sync/atomic" "time" "internetpressure.io/probe-agent/internal/protocol" ) // Timeout per lookup request. const Timeout = 5 * time.Second // RefreshEvery is the hourly refresh interval. const RefreshEvery = time.Hour // Endpoints (overridable in tests). var ( IPInfoURL = "https://ipinfo.io/json" IPAPIURL = "http://ip-api.com/json/?fields=status,country,countryCode,city,lat,lon,as,org,query" ) // Service caches the last identity. type Service struct { ua string http *http.Client current atomic.Pointer[protocol.Identity] } // New builds a Service using the probe User-Agent. func New(userAgent string) *Service { return &Service{ua: userAgent, http: &http.Client{Timeout: Timeout, Transport: &http.Transport{ Proxy: nil, DisableKeepAlives: true, ForceAttemptHTTP2: true}}} } // Current returns the last discovered identity (nil until the first success). func (s *Service) Current() *protocol.Identity { return s.current.Load() } // Refresh performs one lookup and stores the result. func (s *Service) Refresh(ctx context.Context) (*protocol.Identity, error) { id, err := s.fromIPInfo(ctx) if err != nil { id2, err2 := s.fromIPAPI(ctx) if err2 != nil { return nil, fmt.Errorf("ipinfo: %v; ip-api: %w", err, err2) } id = id2 } s.current.Store(id) return id, nil } // Run refreshes at start-up and then every RefreshEvery; onChange is called after each successful refresh. func (s *Service) Run(ctx context.Context, onResult func(*protocol.Identity, error)) { for { id, err := s.Refresh(ctx) if onResult != nil { onResult(id, err) } wait := RefreshEvery if err != nil { wait = 5 * time.Minute } select { case <-ctx.Done(): return case <-time.After(wait): } } } func (s *Service) get(ctx context.Context, url string, v any) error { ctx, cancel := context.WithTimeout(ctx, Timeout) defer cancel() req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return err } req.Header.Set("User-Agent", s.ua) req.Header.Set("Accept", "application/json") resp, err := s.http.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode/100 != 2 { return fmt.Errorf("http %d", resp.StatusCode) } data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10)) if err != nil { return err } return json.Unmarshal(data, v) } func (s *Service) fromIPInfo(ctx context.Context) (*protocol.Identity, error) { var r struct { IP string `json:"ip"` City string `json:"city"` Region string `json:"region"` Country string `json:"country"` Loc string `json:"loc"` Org string `json:"org"` } if err := s.get(ctx, IPInfoURL, &r); err != nil { return nil, err } if r.IP == "" { return nil, errors.New("no ip in response") } asn, org := ParseASNOrg(r.Org) id := &protocol.Identity{PublicIP: r.IP, ASN: asn, Org: org, Country: r.Country, City: r.City, Source: "ipinfo.io"} if lat, lon, ok := parseLoc(r.Loc); ok { id.Lat, id.Lon = protocol.F(lat), protocol.F(lon) } return id, nil } func (s *Service) fromIPAPI(ctx context.Context) (*protocol.Identity, error) { var r struct { Status string `json:"status"` Country string `json:"country"` CountryCode string `json:"countryCode"` City string `json:"city"` Lat float64 `json:"lat"` Lon float64 `json:"lon"` AS string `json:"as"` Org string `json:"org"` Query string `json:"query"` } if err := s.get(ctx, IPAPIURL, &r); err != nil { return nil, err } if r.Status != "success" || r.Query == "" { return nil, fmt.Errorf("status %q", r.Status) } asn, asOrg := ParseASNOrg(r.AS) org := r.Org if org == "" { org = asOrg } country := r.CountryCode if country == "" { country = r.Country } id := &protocol.Identity{PublicIP: r.Query, ASN: asn, Org: org, Country: country, City: r.City, Source: "ip-api.com"} if r.Lat != 0 || r.Lon != 0 { id.Lat, id.Lon = protocol.F(roundCoord(r.Lat)), protocol.F(roundCoord(r.Lon)) } return id, nil } // ParseASNOrg splits "AS577 Bell Canada" into (577, "Bell Canada"). Without an AS prefix ASN is 0. func ParseASNOrg(s string) (int, string) { s = strings.TrimSpace(s) if s == "" { return 0, "" } fields := strings.Fields(s) if len(fields) > 0 && strings.HasPrefix(strings.ToUpper(fields[0]), "AS") { if n, err := strconv.Atoi(fields[0][2:]); err == nil { return n, strings.TrimSpace(strings.Join(fields[1:], " ")) } } return 0, s } // parseLoc parses "46.79,-71.35" and rounds to 2 decimals (~1 km — city level). func parseLoc(loc string) (float64, float64, bool) { a, b, ok := strings.Cut(loc, ",") if !ok { return 0, 0, false } lat, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64) lon, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64) if err1 != nil || err2 != nil { return 0, 0, false } return roundCoord(lat), roundCoord(lon), true } // roundCoord keeps two decimals: city-level precision, never a street address. func roundCoord(v float64) float64 { return math.Round(v*100) / 100 }