spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
1// Package identity discovers the probe's public network identity (IP, ASN, org, country, city, city-level2// coordinates) from ipinfo.io, falling back to ip-api.com. Nothing more precise than city is ever kept.3package identity45import (6 "context"7 "encoding/json"8 "errors"9 "fmt"10 "io"11 "math"12 "net/http"13 "strconv"14 "strings"15 "sync/atomic"16 "time"1718 "internetpressure.io/probe-agent/internal/protocol"19)2021// Timeout per lookup request.22const Timeout = 5 * time.Second2324// RefreshEvery is the hourly refresh interval.25const RefreshEvery = time.Hour2627// Endpoints (overridable in tests).28var (29 IPInfoURL = "https://ipinfo.io/json"30 IPAPIURL = "http://ip-api.com/json/?fields=status,country,countryCode,city,lat,lon,as,org,query"31)3233// Service caches the last identity.34type Service struct {35 ua string36 http *http.Client37 current atomic.Pointer[protocol.Identity]38}3940// New builds a Service using the probe User-Agent.41func New(userAgent string) *Service {42 return &Service{ua: userAgent, http: &http.Client{Timeout: Timeout, Transport: &http.Transport{43 Proxy: nil, DisableKeepAlives: true, ForceAttemptHTTP2: true}}}44}4546// Current returns the last discovered identity (nil until the first success).47func (s *Service) Current() *protocol.Identity { return s.current.Load() }4849// Refresh performs one lookup and stores the result.50func (s *Service) Refresh(ctx context.Context) (*protocol.Identity, error) {51 id, err := s.fromIPInfo(ctx)52 if err != nil {53 id2, err2 := s.fromIPAPI(ctx)54 if err2 != nil {55 return nil, fmt.Errorf("ipinfo: %v; ip-api: %w", err, err2)56 }57 id = id258 }59 s.current.Store(id)60 return id, nil61}6263// Run refreshes at start-up and then every RefreshEvery; onChange is called after each successful refresh.64func (s *Service) Run(ctx context.Context, onResult func(*protocol.Identity, error)) {65 for {66 id, err := s.Refresh(ctx)67 if onResult != nil {68 onResult(id, err)69 }70 wait := RefreshEvery71 if err != nil {72 wait = 5 * time.Minute73 }74 select {75 case <-ctx.Done():76 return77 case <-time.After(wait):78 }79 }80}8182func (s *Service) get(ctx context.Context, url string, v any) error {83 ctx, cancel := context.WithTimeout(ctx, Timeout)84 defer cancel()85 req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)86 if err != nil {87 return err88 }89 req.Header.Set("User-Agent", s.ua)90 req.Header.Set("Accept", "application/json")91 resp, err := s.http.Do(req)92 if err != nil {93 return err94 }95 defer resp.Body.Close()96 if resp.StatusCode/100 != 2 {97 return fmt.Errorf("http %d", resp.StatusCode)98 }99 data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<10))100 if err != nil {101 return err102 }103 return json.Unmarshal(data, v)104}105106func (s *Service) fromIPInfo(ctx context.Context) (*protocol.Identity, error) {107 var r struct {108 IP string `json:"ip"`109 City string `json:"city"`110 Region string `json:"region"`111 Country string `json:"country"`112 Loc string `json:"loc"`113 Org string `json:"org"`114 }115 if err := s.get(ctx, IPInfoURL, &r); err != nil {116 return nil, err117 }118 if r.IP == "" {119 return nil, errors.New("no ip in response")120 }121 asn, org := ParseASNOrg(r.Org)122 id := &protocol.Identity{PublicIP: r.IP, ASN: asn, Org: org, Country: r.Country, City: r.City, Source: "ipinfo.io"}123 if lat, lon, ok := parseLoc(r.Loc); ok {124 id.Lat, id.Lon = protocol.F(lat), protocol.F(lon)125 }126 return id, nil127}128129func (s *Service) fromIPAPI(ctx context.Context) (*protocol.Identity, error) {130 var r struct {131 Status string `json:"status"`132 Country string `json:"country"`133 CountryCode string `json:"countryCode"`134 City string `json:"city"`135 Lat float64 `json:"lat"`136 Lon float64 `json:"lon"`137 AS string `json:"as"`138 Org string `json:"org"`139 Query string `json:"query"`140 }141 if err := s.get(ctx, IPAPIURL, &r); err != nil {142 return nil, err143 }144 if r.Status != "success" || r.Query == "" {145 return nil, fmt.Errorf("status %q", r.Status)146 }147 asn, asOrg := ParseASNOrg(r.AS)148 org := r.Org149 if org == "" {150 org = asOrg151 }152 country := r.CountryCode153 if country == "" {154 country = r.Country155 }156 id := &protocol.Identity{PublicIP: r.Query, ASN: asn, Org: org, Country: country, City: r.City, Source: "ip-api.com"}157 if r.Lat != 0 || r.Lon != 0 {158 id.Lat, id.Lon = protocol.F(roundCoord(r.Lat)), protocol.F(roundCoord(r.Lon))159 }160 return id, nil161}162163// ParseASNOrg splits "AS577 Bell Canada" into (577, "Bell Canada"). Without an AS prefix ASN is 0.164func ParseASNOrg(s string) (int, string) {165 s = strings.TrimSpace(s)166 if s == "" {167 return 0, ""168 }169 fields := strings.Fields(s)170 if len(fields) > 0 && strings.HasPrefix(strings.ToUpper(fields[0]), "AS") {171 if n, err := strconv.Atoi(fields[0][2:]); err == nil {172 return n, strings.TrimSpace(strings.Join(fields[1:], " "))173 }174 }175 return 0, s176}177178// parseLoc parses "46.79,-71.35" and rounds to 2 decimals (~1 km — city level).179func parseLoc(loc string) (float64, float64, bool) {180 a, b, ok := strings.Cut(loc, ",")181 if !ok {182 return 0, 0, false183 }184 lat, err1 := strconv.ParseFloat(strings.TrimSpace(a), 64)185 lon, err2 := strconv.ParseFloat(strings.TrimSpace(b), 64)186 if err1 != nil || err2 != nil {187 return 0, 0, false188 }189 return roundCoord(lat), roundCoord(lon), true190}191192// roundCoord keeps two decimals: city-level precision, never a street address.193func roundCoord(v float64) float64 { return math.Round(v*100) / 100 }194