SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
5.3 KB · 194 lines go
Raw Blame History
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