A Network tab, with an internet speed test
Per machine like every other tab: the interfaces as the kernel has them
(addresses, MAC, MTU, negotiated link speed, carrier, totals, live rates from
the sampler's own sensors), the default route and resolvers, what the internet
sees of this connection, and a speed test with the last fifty results kept.
The test runs in the daemon, not the browser, so the figure is the line of the
machine in the switcher — the router's, if you switch to gate — and it carries
on when the phone locks. It measures against Cloudflare's public speed
endpoints, so there is nothing to install on any host, which is what makes it
work the same on Debian and on OpenWrt where flash is scarce.
What the measurement does and does not count:
- Several streams, HTTP/2 deliberately off. One stream over one connection
measures a congestion window, and HTTP/2 would put every stream back onto
one connection and measure the same thing.
- The first second of each phase is thrown away as the ramp.
- A phase ends on 64 MiB as well as on four seconds, so a fast line says what
it is without moving a gigabyte. A minimum window keeps a very fast line
from filling the cap before there is anything to divide by.
- Latency is the round trip less the far end's own Server-Timing. A zero-byte
response is mostly Cloudflare's worker thinking: left in, it reported 31 ms
for a line the edge itself measures at 3.5. Jitter is the mean change
between consecutive probes, so one slow probe cannot run away with it.
- The far end rate-limits by volume. So: a 20 s cooldown counted from the last
attempt rather than the last success, streams that retry twice, the rate
computed over the window in which bytes were actually still arriving, and a
plain sentence when it was refused. Coasting to the end of the clock with
nothing arriving reported a third of the truth.
The last result is published as net.internet.* sensors, each stamped with when
the test ran, so the line can be a widget, a graph, an alert or a Prometheus
series like anything else. The public address is refreshed on its own quarter
hour, which is worth having here: it is a DHCP lease and it moves.
Tests are offline — the window, the byte cap, the refusal and the cancellation
run against a local server; the route parser's byte order is pinned by a case
that would have caught the bug it did. The live measurement is behind
HAMLAB_NET_TEST=1.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a0987c940f
commit
2f59a54678
@@ -0,0 +1,360 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user