// Package network is what the Network tab shows: the machine's interfaces as the kernel has // them, how the internet looks from out there, and a speed test that measures the line. // // Everything here is read from /proc and /sys, or measured against Cloudflare's public speed // endpoints — no tool to install, on any of the machines. That matters for the router, where // flash is small and `opkg install` is a decision. package network import ( "bufio" "encoding/binary" "fmt" "net" "os" "sort" "strconv" "strings" "sync" "time" ) // Addr is one address on an interface. type Addr struct { IP string `json:"ip"` Prefix int `json:"prefix"` // network bits, as in /24 V6 bool `json:"v6,omitempty"` } // Iface is one network interface: what it is, whether it is up, and what it has moved. type Iface struct { Name string `json:"name"` Kind string `json:"kind"` // ethernet | wifi | bridge | tunnel | loopback | virtual MAC string `json:"mac,omitempty"` MTU int `json:"mtu"` // Up is the administrative state, Carrier whether a cable (or an association) is actually // there. Up without Carrier is the usual "it's plugged into nothing". Up bool `json:"up"` Carrier bool `json:"carrier"` // SpeedMbps is what the link negotiated, where the driver says: 0 for wireless, virtual // and tunnel devices, which have no such number. SpeedMbps int `json:"speed_mbps,omitempty"` Duplex string `json:"duplex,omitempty"` Driver string `json:"driver,omitempty"` SignalDbm *float64 `json:"signal_dbm,omitempty"` // wireless only, from /proc/net/wireless Addrs []Addr `json:"addrs"` RxTotal uint64 `json:"rx_total"` TxTotal uint64 `json:"tx_total"` // Default marks the interface the default route leaves by — the one that matters when // the question is about the internet. Default bool `json:"default,omitempty"` } // Report is the machine's network as it stands. type Report struct { At time.Time `json:"at"` Host string `json:"host,omitempty"` Ifaces []Iface `json:"ifaces"` Gateway string `json:"gateway,omitempty"` GatewayVia string `json:"gateway_via,omitempty"` // the interface it is reached through DNS []string `json:"dns,omitempty"` Search []string `json:"search,omitempty"` } func readStr(path string) string { b, err := os.ReadFile(path) if err != nil { return "" } return strings.TrimSpace(string(b)) } func readInt(path string) int { n, err := strconv.Atoi(readStr(path)) if err != nil { return 0 } return n } // kindOf names what sort of device this is, from what the kernel exposes about it rather // than from its name — a bridge is a bridge whatever it is called. func kindOf(name string, flags net.Flags) string { sys := "/sys/class/net/" + name + "/" switch { case flags&net.FlagLoopback != 0: return "loopback" case dirExists(sys + "wireless"), dirExists(sys + "phy80211"): return "wifi" case dirExists(sys + "bridge"): return "bridge" case dirExists(sys + "bonding"): return "bond" } // DEVTYPE says vlan, bridge, wlan… for the devices that have one for _, line := range strings.Split(readStr(sys+"uevent"), "\n") { if v, ok := strings.CutPrefix(line, "DEVTYPE="); ok && v != "" { return v } } if !dirExists(sys + "device") { // no bus device behind it: veth, tun, wireguard, docker's own return "virtual" } return "ethernet" } func dirExists(p string) bool { st, err := os.Stat(p) return err == nil && st.IsDir() } // driverOf is the module behind an interface, which is what names the hardware in one word. func driverOf(name string) string { l, err := os.Readlink("/sys/class/net/" + name + "/device/driver") if err != nil { return "" } return l[strings.LastIndexByte(l, '/')+1:] } // wirelessSignal is the signal level of each wireless interface, in dBm. func wirelessSignal() map[string]float64 { out := map[string]float64{} f, err := os.Open("/proc/net/wireless") if err != nil { return out } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { name, rest, ok := strings.Cut(sc.Text(), ":") if !ok { continue } fs := strings.Fields(rest) if len(fs) < 3 { continue } if v, err := strconv.ParseFloat(strings.TrimSuffix(fs[2], "."), 64); err == nil { out[strings.TrimSpace(name)] = v } } return out } // counters is every interface's byte totals, from the same file the sampler reads. func counters() map[string][2]uint64 { out := map[string][2]uint64{} f, err := os.Open("/proc/net/dev") if err != nil { return out } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { name, rest, ok := strings.Cut(sc.Text(), ":") if !ok { continue } fs := strings.Fields(rest) if len(fs) < 16 { continue } rx, _ := strconv.ParseUint(fs[0], 10, 64) tx, _ := strconv.ParseUint(fs[8], 10, 64) out[strings.TrimSpace(name)] = [2]uint64{rx, tx} } return out } // defaultRoute is the gateway the machine sends the internet to, and the interface it // leaves by. Read from /proc/net/route, whose addresses are little-endian hex. func defaultRoute() (gw, via string) { f, err := os.Open("/proc/net/route") if err != nil { return "", "" } defer f.Close() sc := bufio.NewScanner(f) best := -1 for sc.Scan() { fs := strings.Fields(sc.Text()) if len(fs) < 8 || fs[0] == "Iface" { continue } if fs[1] != "00000000" { // not a default route continue } metric, _ := strconv.Atoi(fs[6]) if best >= 0 && metric >= best { continue } ip, err := hexIP(fs[2]) if err != nil { continue } best, gw, via = metric, ip, fs[0] } return gw, via } // hexIP reads one address out of /proc/net/route, where each is printed as a 32-bit word in // the host's byte order: 10.0.0.1 appears as "0100000A" on a little-endian machine. So the // text is parsed as a plain number, then written back out in native order to recover the four // bytes — which is right on either sort of machine, without asking which this is. func hexIP(h string) (string, error) { v, err := strconv.ParseUint(h, 16, 32) if err != nil || len(h) != 8 { return "", fmt.Errorf("not an address: %q", h) } var b [4]byte binary.NativeEndian.PutUint32(b[:], uint32(v)) return net.IPv4(b[0], b[1], b[2], b[3]).String(), nil } // resolvers is what /etc/resolv.conf points at. func resolvers() (servers, search []string) { f, err := os.Open("/etc/resolv.conf") if err != nil { return nil, nil } defer f.Close() sc := bufio.NewScanner(f) for sc.Scan() { fs := strings.Fields(sc.Text()) if len(fs) < 2 || strings.HasPrefix(fs[0], "#") { continue } switch fs[0] { case "nameserver": servers = append(servers, fs[1]) case "search", "domain": search = append(search, fs[1:]...) } } return servers, search } // Interfaces is every interface the kernel has, with its addresses and totals. func Interfaces() []Iface { list, err := net.Interfaces() if err != nil { return nil } _, via := defaultRoute() sig, cnt := wirelessSignal(), counters() out := make([]Iface, 0, len(list)) for _, in := range list { sys := "/sys/class/net/" + in.Name + "/" f := Iface{ Name: in.Name, Kind: kindOf(in.Name, in.Flags), MTU: in.MTU, MAC: in.HardwareAddr.String(), Up: in.Flags&net.FlagUp != 0, Driver: driverOf(in.Name), Default: in.Name == via, Addrs: []Addr{}, } f.Carrier = readStr(sys+"carrier") == "1" if f.Kind == "loopback" { f.Carrier = f.Up } if sp := readInt(sys + "speed"); sp > 0 { // -1 when the driver doesn't know f.SpeedMbps = sp } if d := readStr(sys + "duplex"); d != "" && d != "unknown" { f.Duplex = d } if v, ok := sig[in.Name]; ok { f.SignalDbm = &v } if c, ok := cnt[in.Name]; ok { f.RxTotal, f.TxTotal = c[0], c[1] } addrs, _ := in.Addrs() for _, a := range addrs { n, ok := a.(*net.IPNet) if !ok { continue } ones, _ := n.Mask.Size() f.Addrs = append(f.Addrs, Addr{IP: n.IP.String(), Prefix: ones, V6: n.IP.To4() == nil}) } out = append(out, f) } // the interface the internet leaves by first, then the ones carrying addresses, then the rest sort.SliceStable(out, func(i, j int) bool { a, b := out[i], out[j] if a.Default != b.Default { return a.Default } ai, bi := len(a.Addrs) > 0 && a.Kind != "loopback", len(b.Addrs) > 0 && b.Kind != "loopback" if ai != bi { return ai } return a.Name < b.Name }) return out } // Look is the machine's network now. Cheap: a handful of small files. func Look(host string) Report { gw, via := defaultRoute() dns, search := resolvers() return Report{At: time.Now(), Host: host, Ifaces: Interfaces(), Gateway: gw, GatewayVia: via, DNS: dns, Search: search} } // ---- how the internet sees this machine ---- // Outside is what the far end of the line says about this connection: the address the // internet sees, and where it came ashore. Refreshed on its own clock, because the public // address here is a DHCP lease and moves without warning. type Outside struct { At time.Time `json:"at"` IP string `json:"ip,omitempty"` Colo string `json:"colo,omitempty"` // the Cloudflare edge that answered — "SEA" Country string `json:"country,omitempty"` Error string `json:"error,omitempty"` } // Watcher keeps the outside view fresh and holds the speed tester. type Watcher struct { mu sync.RWMutex out Outside Test *Tester } const outsideEvery = 15 * time.Minute func NewWatcher(onResult func(Result)) *Watcher { w := &Watcher{Test: NewTester(onResult)} go func() { for { w.refresh() time.Sleep(outsideEvery) } }() return w } func (w *Watcher) refresh() { o := probeOutside() w.mu.Lock() // a failed probe does not throw away a good answer; it is noted beside it if o.IP == "" && w.out.IP != "" { w.out.Error, w.out.At = o.Error, o.At } else { w.out = o } w.mu.Unlock() } // Outside is the last known view from outside, asking again if it has gone stale. func (w *Watcher) Outside() Outside { w.mu.RLock() o := w.out w.mu.RUnlock() if time.Since(o.At) > outsideEvery { w.refresh() w.mu.RLock() o = w.out w.mu.RUnlock() } return o }