Files
tslink/netdiag/geo.go
T
2026-07-26 09:39:17 +00:00

471 lines
14 KiB
Go

package netdiag
// This file resolves public IP addresses to a rough location and network
// operator.
//
// PRIVACY: every lookup here sends the user's own public IP address to a
// third-party API (ipinfo.io, ip-api.com, api.ip.sb). Those services see the
// address, the timestamp and our source IP — which for a direct query is that
// very same address. Nothing else is sent: no hostname, no tailnet identity,
// no credentials. Callers who are not comfortable with that must set
// [Options.SkipGeo], which exists precisely for this reason, and no request in
// this file will be made. The ipinfo token, when configured, is passed as a
// query parameter to that provider only and is never logged or stored in a
// report.
//
// Providers are tried in order and the first usable answer wins. The order is
// not arbitrary: ipinfo.io is the most accurate but rate-limits hard without a
// token, ip-api.com is HTTP-only on the free tier yet stays reachable from
// mainland China, and api.ip.sb is the last resort.
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/netip"
"net/url"
"sort"
"strconv"
"strings"
"sync"
"time"
)
const (
// geoTimeout bounds one provider query.
geoTimeout = 4 * time.Second
// geoMaxBody caps a provider response. Well-behaved answers are a few
// hundred bytes; the cap guards against a hijacked or error page.
geoMaxBody = 64 << 10
// geoMaxInflight bounds concurrent lookups in [AnnotateGeo]. Kept low
// because the free tiers of these APIs rate-limit per source IP.
geoMaxInflight = 4
)
// geoCGNAT is RFC 6598 shared address space. Tailscale also allocates node
// addresses out of it, and either way no geolocation provider can say anything
// useful about such an address.
var geoCGNAT = netip.MustParsePrefix("100.64.0.0/10")
// geoSkipErr is reported for addresses that are not globally routable.
const geoSkipErr = "私有地址,跳过查询"
func geoLog(logger *slog.Logger) *slog.Logger {
if logger == nil {
logger = slog.Default()
}
return logger.With(slog.String("from", "netdiag/geo"))
}
// geoSkippable reports whether ip is not worth (or not safe to) look up:
// loopback, RFC1918/ULA private, link-local, CGNAT, multicast or unspecified.
func geoSkippable(ip netip.Addr) bool {
ip = ip.Unmap()
if !ip.IsValid() {
return true
}
if ip.Is4() && geoCGNAT.Contains(ip) {
return true
}
return ip.IsLoopback() ||
ip.IsPrivate() ||
ip.IsLinkLocalUnicast() ||
ip.IsLinkLocalMulticast() ||
ip.IsMulticast() ||
ip.IsUnspecified()
}
// geoProvider is one geolocation backend.
type geoProvider struct {
name string
// fetch fills a GeoInfo from the provider, or returns an error so the next
// provider is tried.
fetch func(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error)
}
// geoProviders returns the backends in the order they are tried.
func geoProviders() []geoProvider {
return []geoProvider{
{name: "ipinfo.io", fetch: geoFetchIPInfo},
{name: "ip-api.com", fetch: geoFetchIPAPI},
{name: "ip.sb", fetch: geoFetchIPSB},
}
}
// LookupGeo resolves one address to a location and operator.
//
// Providers are tried in order until one answers; the returned GeoInfo names
// the provider that did in its Provider field. When all of them fail, Provider
// is empty and Err holds the last error. Addresses that are not globally
// routable are never sent anywhere: they come back immediately with Err set to
// "私有地址,跳过查询".
//
// token is an optional ipinfo.io API token; it raises that provider's rate
// limit and is never logged.
//
// Each provider gets its own ~4s budget, so the whole call is bounded even if
// every backend hangs. See the privacy note at the top of this file.
func LookupGeo(ctx context.Context, ip netip.Addr, token string, logger *slog.Logger) GeoInfo {
log := geoLog(logger)
ip = ip.Unmap().WithZone("")
if geoSkippable(ip) {
return GeoInfo{IP: ip, Err: geoSkipErr}
}
var lastErr string
for _, p := range geoProviders() {
if ctx.Err() != nil {
return GeoInfo{IP: ip, Err: rchErrText(ctx.Err())}
}
pctx, cancel := context.WithTimeout(ctx, geoTimeout)
info, err := p.fetch(pctx, ip, token)
cancel()
if err != nil {
lastErr = fmt.Sprintf("%s: %s", p.name, rchErrText(err))
log.With(
slog.String("ip", ip.String()),
slog.String("provider", p.name),
slog.String("error", rchErrText(err)),
).Debug("geo provider failed")
continue
}
info.IP = ip
info.Provider = p.name
log.With(
slog.String("ip", ip.String()),
slog.String("provider", p.name),
slog.String("country", info.Country),
slog.String("asn", info.ASN),
).Debug("resolved ip location")
return info
}
if lastErr == "" {
lastErr = "no geolocation provider answered"
}
return GeoInfo{IP: ip, Err: lastErr}
}
// AnnotateGeo fills rep.Geo and rep.Countries for every address in
// rep.UniqueIPs and recomputes rep.Summary. It is a no-op when the report has
// no addresses.
//
// Lookups run concurrently but at most [geoMaxInflight] at a time, since the
// free tiers rate-limit per source IP. Results are sorted by address and the
// country list is deduplicated, so repeated runs render identically.
//
// This function performs third-party network requests; see the privacy note at
// the top of this file and [Options.SkipGeo].
func AnnotateGeo(ctx context.Context, rep *EgressReport, token string, logger *slog.Logger) {
if rep == nil || len(rep.UniqueIPs) == 0 {
return
}
log := geoLog(logger)
var (
mu sync.Mutex
out = make([]GeoInfo, 0, len(rep.UniqueIPs))
wg sync.WaitGroup
sem = make(chan struct{}, geoMaxInflight)
)
for _, ip := range rep.UniqueIPs {
wg.Add(1)
go func(ip netip.Addr) {
defer wg.Done()
select {
case sem <- struct{}{}:
defer func() { <-sem }()
case <-ctx.Done():
mu.Lock()
out = append(out, GeoInfo{IP: ip, Err: rchErrText(ctx.Err())})
mu.Unlock()
return
}
info := LookupGeo(ctx, ip, token, log)
mu.Lock()
out = append(out, info)
mu.Unlock()
}(ip)
}
wg.Wait()
sort.Slice(out, func(i, j int) bool { return out[i].IP.Compare(out[j].IP) < 0 })
rep.Geo = out
rep.Countries = geoCountries(out)
egFinish(rep)
log.With(
slog.Int("addrs", len(rep.Geo)),
slog.String("countries", strings.Join(rep.Countries, ",")),
).Debug("annotated egress addresses with geolocation")
}
// geoCountries returns the sorted, deduplicated set of countries seen,
// preferring the ISO code and falling back to the localised name when a
// provider only supplied that.
func geoCountries(gs []GeoInfo) []string {
seen := make(map[string]struct{}, len(gs))
var out []string
for _, g := range gs {
name := strings.TrimSpace(g.Country)
if name == "" {
name = strings.TrimSpace(g.CountryName)
}
if name == "" {
continue
}
if _, dup := seen[name]; dup {
continue
}
seen[name] = struct{}{}
out = append(out, name)
}
sort.Strings(out)
return out
}
// ---------------------------------------------------------------------------
// Provider implementations
// ---------------------------------------------------------------------------
// geoGetJSON fetches url and decodes the body into v. The client uses the
// unforced network and honours the environment proxy: unlike the egress
// probes, we do not care which path the query takes, only that it succeeds.
func geoGetJSON(ctx context.Context, target string, v any) error {
client := newDiagClient("", true, geoTimeout)
defer client.CloseIdleConnections()
hdr := http.Header{}
hdr.Set("Accept", "application/json")
code, body, _, err := diagGet(ctx, client, target, geoMaxBody, hdr)
if err != nil {
return err
}
if code < 200 || code > 299 {
return fmt.Errorf("unexpected status %d", code)
}
if err := json.Unmarshal(body, v); err != nil {
return fmt.Errorf("bad json: %w", err)
}
return nil
}
// geoIPInfoResp is the subset of ipinfo.io's answer we use.
type geoIPInfoResp struct {
IP string `json:"ip"`
City string `json:"city"`
Region string `json:"region"`
Country string `json:"country"`
CountryName string `json:"country_name"` // only on paid plans
Loc string `json:"loc"`
Org string `json:"org"`
Timezone string `json:"timezone"`
Bogon bool `json:"bogon"`
}
// geoFetchIPInfo queries ipinfo.io. The token, when non-empty, only raises the
// rate limit; it is appended as a query parameter and never logged.
func geoFetchIPInfo(ctx context.Context, ip netip.Addr, token string) (GeoInfo, error) {
target := "https://ipinfo.io/" + url.PathEscape(ip.String()) + "/json"
if token != "" {
target += "?token=" + url.QueryEscape(token)
}
var r geoIPInfoResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if r.Bogon {
return GeoInfo{}, fmt.Errorf("provider reports bogon address")
}
if r.Country == "" && r.Org == "" && r.City == "" {
return GeoInfo{}, fmt.Errorf("empty answer")
}
asn, org := geoSplitOrg(r.Org)
return GeoInfo{
Country: strings.TrimSpace(r.Country),
CountryName: strings.TrimSpace(r.CountryName),
Region: strings.TrimSpace(r.Region),
City: strings.TrimSpace(r.City),
Org: org,
ASN: asn,
Loc: strings.TrimSpace(r.Loc),
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoIPAPIResp is ip-api.com's answer for the field set we request.
type geoIPAPIResp struct {
Status string `json:"status"`
Message string `json:"message"`
Country string `json:"country"`
CountryCode string `json:"countryCode"`
RegionName string `json:"regionName"`
City string `json:"city"`
ISP string `json:"isp"`
Org string `json:"org"`
AS string `json:"as"`
Timezone string `json:"timezone"`
}
// geoFetchIPAPI queries ip-api.com. The free tier is HTTP-only, which is also
// why it keeps working from mainland China where the HTTPS providers often do
// not. Answers are requested in Chinese to match the rest of the UI.
func geoFetchIPAPI(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
target := "http://ip-api.com/json/" + url.PathEscape(ip.String()) +
"?lang=zh-CN&fields=status,message,country,countryCode,regionName,city,isp,org,as,timezone"
var r geoIPAPIResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if !strings.EqualFold(r.Status, "success") {
msg := strings.TrimSpace(r.Message)
if msg == "" {
msg = r.Status
}
return GeoInfo{}, fmt.Errorf("query failed: %s", msg)
}
asn, asOrg := geoSplitOrg(r.AS)
org := strings.TrimSpace(r.Org)
if org == "" {
org = strings.TrimSpace(r.ISP)
}
if org == "" {
org = asOrg
}
return GeoInfo{
Country: strings.TrimSpace(r.CountryCode),
CountryName: strings.TrimSpace(r.Country),
Region: strings.TrimSpace(r.RegionName),
City: strings.TrimSpace(r.City),
Org: org,
ASN: asn,
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoIPSBResp is api.ip.sb's answer. ASN comes back as a bare number, so it is
// decoded loosely and normalised by [geoASNText].
type geoIPSBResp struct {
Country string `json:"country"`
CountryCode string `json:"country_code"`
Region string `json:"region"`
City string `json:"city"`
ISP string `json:"isp"`
ASN any `json:"asn"`
ASNOrg string `json:"asn_organization"`
Timezone string `json:"timezone"`
Latitude any `json:"latitude"`
Longitude any `json:"longitude"`
}
// geoFetchIPSB queries api.ip.sb, the last-resort provider.
func geoFetchIPSB(ctx context.Context, ip netip.Addr, _ string) (GeoInfo, error) {
target := "https://api.ip.sb/geoip/" + url.PathEscape(ip.String())
var r geoIPSBResp
if err := geoGetJSON(ctx, target, &r); err != nil {
return GeoInfo{}, err
}
if r.CountryCode == "" && r.Country == "" && r.ISP == "" {
return GeoInfo{}, fmt.Errorf("empty answer")
}
org := strings.TrimSpace(r.ISP)
if org == "" {
org = strings.TrimSpace(r.ASNOrg)
}
return GeoInfo{
Country: strings.TrimSpace(r.CountryCode),
CountryName: strings.TrimSpace(r.Country),
Region: strings.TrimSpace(r.Region),
City: strings.TrimSpace(r.City),
Org: org,
ASN: geoASNText(r.ASN),
Loc: geoLocText(r.Latitude, r.Longitude),
Timezone: strings.TrimSpace(r.Timezone),
}, nil
}
// geoSplitOrg splits an "AS4134 Chinanet" style string into the ASN and the
// operator name. Either half may be missing, in which case the whole string is
// treated as the operator name.
func geoSplitOrg(s string) (asn, org string) {
s = strings.TrimSpace(s)
if s == "" {
return "", ""
}
head, rest, _ := strings.Cut(s, " ")
if geoLooksLikeASN(head) {
return head, strings.TrimSpace(rest)
}
return "", s
}
// geoLooksLikeASN reports whether s is an "AS####" token.
func geoLooksLikeASN(s string) bool {
if len(s) < 3 || !strings.EqualFold(s[:2], "AS") {
return false
}
_, err := strconv.ParseUint(s[2:], 10, 32)
return err == nil
}
// geoASNText normalises a JSON asn field (number or string) to "AS####".
func geoASNText(v any) string {
var s string
switch n := v.(type) {
case nil:
return ""
case float64:
if n <= 0 {
return ""
}
s = strconv.FormatFloat(n, 'f', -1, 64)
case string:
s = strings.TrimSpace(n)
default:
return ""
}
if s == "" || s == "0" {
return ""
}
if geoLooksLikeASN(s) {
return strings.ToUpper(s[:2]) + s[2:]
}
if _, err := strconv.ParseUint(s, 10, 32); err != nil {
return ""
}
return "AS" + s
}
// geoLocText renders a latitude/longitude pair in ipinfo's "lat,lon" form so
// the Loc field means the same thing whichever provider answered.
func geoLocText(lat, lon any) string {
f := func(v any) (string, bool) {
switch n := v.(type) {
case float64:
return strconv.FormatFloat(n, 'f', -1, 64), true
case string:
s := strings.TrimSpace(n)
return s, s != ""
default:
return "", false
}
}
a, okA := f(lat)
b, okB := f(lon)
if !okA || !okB {
return ""
}
return a + "," + b
}