// Package netinfo says which network an address belongs to: its
// autonomous system's number, name and country.
//
// The traffic page promises that nothing about a reader is sent
// anywhere, so the answer comes from a table held here, not a lookup
// service. The table is iptoasn.com's ip2asn-combined, public and
// refreshed by the forge on a timer; what goes out is a request for the
// whole table, which names nobody.
package netinfo
import (
"bufio"
"bytes"
"compress/gzip"
"context"
"encoding/binary"
"fmt"
"io"
"log"
"net/http"
"net/netip"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync/atomic"
"time"
)
// Network is an autonomous system: the organisation an address belongs
// to, which outlives the address in the traffic store.
type Network struct {
ASN uint32
Name string
Country string
}
// Table answers lookups against one loaded copy of the data.
type Table struct {
v4 []span4
v6 []span6
nets map[uint32]Network
}
type span4 struct{ lo, hi, asn uint32 }
type span6 struct {
lo, hi [16]byte
asn uint32
}
// Lookup is the network an address is in. ok is false for an address
// the table does not route, and for one that does not parse.
func (t *Table) Lookup(addr string) (Network, bool) {
if t == nil {
return Network{}, false
}
a, err := netip.ParseAddr(addr)
if err != nil {
return Network{}, false
}
a = a.Unmap()
var asn uint32
if a.Is4() {
b := a.As4()
x := binary.BigEndian.Uint32(b[:])
i := sort.Search(len(t.v4), func(i int) bool { return t.v4[i].hi >= x })
if i < len(t.v4) && t.v4[i].lo <= x {
asn = t.v4[i].asn
}
} else {
x := a.As16()
i := sort.Search(len(t.v6), func(i int) bool { return bytes.Compare(t.v6[i].hi[:], x[:]) >= 0 })
if i < len(t.v6) && bytes.Compare(t.v6[i].lo[:], x[:]) <= 0 {
asn = t.v6[i].asn
}
}
if asn == 0 {
return Network{}, false
}
return t.nets[asn], true
}
// Parse reads the ip2asn-combined TSV: start, end, AS number, country,
// AS name, one range a line, sorted by start. Unrouted ranges (AS 0)
// are left out, so they answer not-found rather than "AS 0".
func Parse(r io.Reader) (*Table, error) {
t := &Table{nets: map[uint32]Network{}}
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 64<<10), 1<<20)
for sc.Scan() {
f := strings.SplitN(sc.Text(), "\t", 5)
if len(f) < 5 {
continue
}
n, err := strconv.ParseUint(f[2], 10, 32)
if err != nil || n == 0 {
continue
}
lo, err1 := netip.ParseAddr(f[0])
hi, err2 := netip.ParseAddr(f[1])
if err1 != nil || err2 != nil || lo.Is4() != hi.Is4() {
continue
}
asn := uint32(n)
if lo.Is4() {
l, h := lo.As4(), hi.As4()
t.v4 = append(t.v4, span4{binary.BigEndian.Uint32(l[:]), binary.BigEndian.Uint32(h[:]), asn})
} else {
t.v6 = append(t.v6, span6{lo.As16(), hi.As16(), asn})
}
if _, seen := t.nets[asn]; !seen {
t.nets[asn] = Network{ASN: asn, Name: tidyName(f[4]), Country: country(f[3])}
}
}
if err := sc.Err(); err != nil {
return nil, err
}
if len(t.v4) == 0 {
return nil, fmt.Errorf("netinfo: the table has no IPv4 ranges")
}
sort.Slice(t.v4, func(i, j int) bool { return t.v4[i].lo < t.v4[j].lo })
sort.Slice(t.v6, func(i, j int) bool { return bytes.Compare(t.v6[i].lo[:], t.v6[j].lo[:]) < 0 })
return t, nil
}
func country(c string) string {
if len(c) != 2 {
return ""
}
return strings.ToUpper(c)
}
// tidyName drops the registry handle a name usually leads with:
// "HETZNER-AS" stays, but "GOOGLE - Google LLC" reads "Google LLC".
func tidyName(n string) string {
n = strings.TrimSpace(n)
if _, rest, ok := strings.Cut(n, " - "); ok && rest != "" {
return strings.TrimSpace(rest)
}
if handle, rest, ok := strings.Cut(n, " "); ok && rest != "" && strings.ToUpper(handle) == handle {
return strings.TrimSpace(rest)
}
return n
}
// Source is where the table is fetched from.
const Source = "https://iptoasn.com/data/ip2asn-combined.tsv.gz"
// Live holds the current table, swapped whole on refresh.
type Live struct{ t atomic.Pointer[Table] }
// Lookup asks the current table; before one is loaded it knows nothing.
func (l *Live) Lookup(addr string) (Network, bool) { return l.t.Load().Lookup(addr) }
// Keep loads the table cached in dir, then refreshes it from Source
// every week until ctx ends. A forge that cannot reach the source keeps
// the copy it has: a week-old map of the internet is nearly all right,
// and no map leaves every reader's network blank.
func (l *Live) Keep(ctx context.Context, dir string) {
path := filepath.Join(dir, "ip2asn-combined.tsv.gz")
if t, err := load(path); err == nil {
l.t.Store(t)
}
for {
if st, err := os.Stat(path); err != nil || time.Since(st.ModTime()) > 7*24*time.Hour {
if err := fetch(ctx, path); err != nil {
log.Printf("netinfo: refresh: %v", err)
} else if t, err := load(path); err != nil {
log.Printf("netinfo: %v", err)
} else {
l.t.Store(t)
}
}
select {
case <-ctx.Done():
return
case <-time.After(6 * time.Hour):
}
}
}
func load(path string) (*Table, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
z, err := gzip.NewReader(f)
if err != nil {
return nil, fmt.Errorf("netinfo: %s: %w", path, err)
}
return Parse(z)
}
// fetch downloads the table beside path and renames it into place, so a
// cut-off download never replaces a whole one.
func fetch(ctx context.Context, path string) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", Source, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s: %s", Source, resp.Status)
}
tmp := path + ".part"
f, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(f, io.LimitReader(resp.Body, 256<<20)); err != nil {
f.Close()
os.Remove(tmp)
return err
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return err
}
// A table that does not parse is not kept, so the next load is never
// of something worse than what is running.
if _, err := load(tmp); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, path)
}