Skip to content
quarry Artifact [ceeb910f97]
Login

Artifact [ceeb910f97]

Artifact ceeb910f97040dd8c5b2155fdb4176f337ca58e7570a9000065b4519e4e228a6:


// 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)
}