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:
Mitchell-Johnson
2026-09-27 20:11:03 -07:00
co-authored by Claude Opus 5
parent a0987c940f
commit 2f59a54678
15 changed files with 1683 additions and 8 deletions
+648
View File
@@ -0,0 +1,648 @@
package network
// The speed test. It measures against Cloudflare's public speed endpoints, the same ones
// speed.cloudflare.com uses: a plain GET of N bytes down, a plain POST of N bytes up, and a
// zero-byte GET for the round trip. No account, no API key, nothing to install, and an edge
// close to every one of these machines.
//
// The method, since a number without one means little — it is stated in the UI too:
//
// - Latency is the round trip of a zero-byte response on a connection that is already open,
// less the time the far end says it spent on the request (its Server-Timing header). That
// subtraction is the difference between measuring the line and measuring Cloudflare's
// thinking time: a zero-byte response is almost entirely the latter — tens of
// milliseconds of worker, against a few of actual wire. The figure reported is the best of
// several probes; jitter is the average change from one to the next.
// - Download and upload each run several streams at once for a fixed few seconds. One
// stream measures one TCP connection's congestion window as much as the line, which is
// why every speed test ever made uses more than one.
// - The first second of each phase is thrown away. That is the ramp: counting it reports a
// number nobody's connection ever actually did.
// - Each phase is capped in bytes as well as seconds, so a very fast line cannot run away
// with the meter.
//
// The one thing to know about the far end: it rate-limits by volume, and a test moves a few
// hundred megabytes. Several in a row will start earning 429s, which is why there is a
// cooldown between tests and why a phase that gets refused says so instead of reporting the
// decayed figure it would otherwise compute.
//
// HTTP/2 is deliberately off: over one HTTP/2 connection the streams would share a single
// congestion window, which measures the window, not the line.
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"math"
"net"
"net/http"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
)
// The endpoints, and the shape of a phase. Variables rather than constants only so the tests
// can point them at a local server and shrink the windows; nothing else rebinds them.
var (
downURL = "https://speed.cloudflare.com/__down?bytes=%d"
upURL = "https://speed.cloudflare.com/__up"
// traceURL is Cloudflare's standard trace: a few lines of key=value naming the address it
// sees, the edge that answered and the country. A few hundred bytes.
traceURL = "https://speed.cloudflare.com/cdn-cgi/trace"
warmUp = time.Second
downFor = 4 * time.Second
upFor = 4 * time.Second
// measureBytes ends a phase early once this much has moved inside the window. A fast line
// says what it is in well under the time allowed, and stopping there is what keeps a test
// down to a couple of hundred megabytes — which is the difference between the far end
// serving this happily and rate-limiting it. A slow line never reaches it and is timed out
// by downFor/upFor instead, which is the honest way round.
measureBytes = int64(64) << 20
// minWindow is how long the window must last whatever the byte cap says. Without it a very
// fast line fills the cap in a few hundredths of a second and the phase would be over
// before there was anything to divide by.
minWindow = time.Second
)
const (
latencyProbes = 8
downStreams = 4
upStreams = 3
downChunk = 25 << 20 // bytes per download request; the far end refuses much more
upChunk = 25 << 20 // and per upload request
// phaseBudget is the runaway guard, counting the ramp too: nothing measures forever.
phaseBudget = int64(512) << 20
httpTimeout = 90 * time.Second
// Cooldown is how long the line is left alone between tests. The far end rate-limits by
// volume, and two back-to-back tests are the surest way to be refused by it.
Cooldown = 20 * time.Second
// Method is what the UI tells the reader about the numbers it is showing.
Method = "Cloudflare's speed endpoints · 4 streams down, 3 up · a 1 s ramp, then up to 4 s or 64 MiB each way"
)
// Result is one finished speed test.
type Result struct {
At time.Time `json:"at"`
DownMbps float64 `json:"down_mbps"`
UpMbps float64 `json:"up_mbps"`
LatencyMs float64 `json:"latency_ms"`
JitterMs float64 `json:"jitter_ms"`
DownBytes int64 `json:"down_bytes"`
UpBytes int64 `json:"up_bytes"`
TookSec float64 `json:"took_sec"`
// Colo is the Cloudflare edge that answered — "SEA" — and IP is this connection's public
// address as that edge saw it.
Colo string `json:"colo,omitempty"`
IP string `json:"ip,omitempty"`
// Note is set when the figures are real but something went wrong along the way — a stream
// that was refused part-way, most often. The number is what was actually seen; the note is
// why it may be low.
Note string `json:"note,omitempty"`
Error string `json:"error,omitempty"`
}
// Run is a test in progress, or the last one to have finished.
type Run struct {
Started time.Time `json:"started"`
Running bool `json:"running"`
// Phase is where it has got to: latency, download, upload, done.
Phase string `json:"phase"`
// Done is how far through the whole test, 0–1, so one bar can show all of it.
Done float64 `json:"done"`
// Mbps is the live figure for the phase under way — what the needle shows.
Mbps float64 `json:"mbps"`
Bytes int64 `json:"bytes"`
Result *Result `json:"result,omitempty"`
}
// Tester runs one speed test at a time and remembers what the last ones said.
type Tester struct {
mu sync.RWMutex
run Run
hist []Result
// lastTry is when a test was last *started*, which is what the cooldown counts from — a
// test that was refused still used the line, and hammering retries at an endpoint that has
// just rate-limited us is the one thing guaranteed not to help.
lastTry time.Time
cancel context.CancelFunc
onDone func(Result)
maxHist int
}
func NewTester(onDone func(Result)) *Tester {
return &Tester{onDone: onDone, maxHist: 50}
}
// Load takes the results kept from before a restart, oldest first.
func (t *Tester) Load(rs []Result) {
t.mu.Lock()
defer t.mu.Unlock()
t.hist = append(t.hist[:0], rs...)
if n := len(t.hist); n > 0 {
last := t.hist[n-1]
t.run = Run{Started: last.At, Phase: "done", Done: 1, Result: &last}
t.lastTry = last.At
}
}
func (t *Tester) State() Run {
t.mu.RLock()
defer t.mu.RUnlock()
return t.run
}
func (t *Tester) History() []Result {
t.mu.RLock()
defer t.mu.RUnlock()
return append([]Result{}, t.hist...)
}
func (t *Tester) Running() bool {
t.mu.RLock()
defer t.mu.RUnlock()
return t.run.Running
}
// Cancel stops a test in progress. Reports whether there was one.
func (t *Tester) Cancel() bool {
t.mu.Lock()
c := t.cancel
t.mu.Unlock()
if c == nil {
return false
}
c()
return true
}
var ErrBusy = errors.New("a speed test is already running")
// Start begins a test in the background and returns its opening state.
func (t *Tester) Start() (Run, error) {
t.mu.Lock()
if t.run.Running {
r := t.run
t.mu.Unlock()
return r, ErrBusy
}
if since := time.Since(t.lastTry); since < Cooldown && !t.lastTry.IsZero() {
r := t.run
t.mu.Unlock()
return r, fmt.Errorf("give the line %d more seconds — testing back to back is what gets this connection rate-limited", int((Cooldown-since).Seconds())+1)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
t.cancel = cancel
t.lastTry = time.Now()
t.run = Run{Started: t.lastTry, Running: true, Phase: "latency"}
r := t.run
t.mu.Unlock()
go t.measure(ctx, cancel)
return r, nil
}
func (t *Tester) set(fn func(*Run)) {
t.mu.Lock()
fn(&t.run)
t.mu.Unlock()
}
func (t *Tester) measure(ctx context.Context, cancel context.CancelFunc) {
defer cancel()
started := time.Now()
res := Result{At: started}
tr := newTransport(downStreams)
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr, Timeout: httpTimeout}
// latency, and the meta the same responses carry
lat, meta, err := t.latency(ctx, client)
if err == nil {
res.LatencyMs, res.JitterMs = best(lat), jitter(lat)
res.Colo, res.IP = meta.Colo, meta.IP
}
var note string
if err == nil {
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "download", 0.1, 0 })
res.DownMbps, res.DownBytes, note, err = t.phase(ctx, client, "download", downStreams, downFor, 0.1, 0.6)
res.Note = join(res.Note, note)
}
if err == nil {
t.set(func(r *Run) { r.Phase, r.Done, r.Mbps = "upload", 0.7, 0 })
res.UpMbps, res.UpBytes, note, err = t.phase(ctx, client, "upload", upStreams, upFor, 0.7, 1.0)
res.Note = join(res.Note, note)
}
res.TookSec = round(time.Since(started).Seconds(), 1)
if err != nil {
if ctx.Err() != nil {
res.Error = "cancelled"
} else {
res.Error = err.Error()
}
}
t.mu.Lock()
t.cancel = nil
t.run = Run{Started: started, Phase: "done", Done: 1, Result: &res, Bytes: res.DownBytes + res.UpBytes}
if res.Error == "" || res.DownMbps > 0 { // a cancelled test that got a number is still a reading
t.hist = append(t.hist, res)
if len(t.hist) > t.maxHist {
t.hist = t.hist[len(t.hist)-t.maxHist:]
}
}
done, out := t.onDone, res
t.mu.Unlock()
if done != nil {
done(out)
}
}
// meta is what the edge says about the connection. The address comes from a header of the
// speed endpoint's own; the edge's name is the tail of the ray ID, which every Cloudflare
// response carries ("a41f6720f966a36c-SEA").
type meta struct{ IP, Colo string }
func readMeta(h http.Header) meta {
m := meta{IP: h.Get("cf-meta-ip")}
if ray := h.Get("cf-ray"); ray != "" {
if i := strings.LastIndexByte(ray, '-'); i >= 0 {
m.Colo = ray[i+1:]
}
}
return m
}
// serverMs is how long the far end says it spent on this request, from Server-Timing. The
// cfL4 entry carries no dur= and contributes nothing, which is what we want — it is a
// description of the connection, not time spent.
func serverMs(h http.Header) float64 {
var total float64
for _, v := range h.Values("Server-Timing") {
for _, part := range strings.Split(v, ",") {
_, after, ok := strings.Cut(part, "dur=")
if !ok {
continue
}
end := 0
for end < len(after) && (after[end] == '.' || (after[end] >= '0' && after[end] <= '9')) {
end++
}
if f, err := strconv.ParseFloat(after[:end], 64); err == nil {
total += f
}
}
}
return total
}
// latency times several zero-byte responses on an already-open connection.
func (t *Tester) latency(ctx context.Context, client *http.Client) ([]float64, meta, error) {
var m meta
var out []float64
for i := 0; i <= latencyProbes; i++ { // the first is the handshake, and is not counted
start := time.Now()
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(downURL, 0), nil)
if err != nil {
return nil, m, err
}
resp, err := client.Do(req)
if err != nil {
return nil, m, err
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 {
return nil, m, fmt.Errorf("speed endpoint answered %s", resp.Status)
}
if i == 0 {
m = readMeta(resp.Header)
continue
}
rtt := float64(time.Since(start).Microseconds())/1000 - serverMs(resp.Header)
if rtt < 0.01 { // the far end claimed more time than the whole request took
rtt = 0.01
}
out = append(out, rtt)
t.set(func(r *Run) { r.Done = 0.02 + 0.08*float64(i)/latencyProbes })
}
if len(out) == 0 {
return nil, m, errors.New("no reply from the speed endpoint")
}
return out, m, nil
}
// phase pushes traffic with several streams for a fixed spell and reports the rate over the
// window after the ramp. from and to are where this phase sits in the whole test's progress.
//
// The window ends when the traffic does, not when the clock runs out: if the far end starts
// refusing part-way, the figure is what was actually moved while it was moving, and the note
// says what happened. Coasting to the end of the clock with no bytes arriving would report a
// number a third of the truth.
func (t *Tester) phase(parent context.Context, client *http.Client, dir string, streams int, forHow time.Duration, from, to float64) (float64, int64, string, error) {
// parent is the test's own context, which is cancelled when somebody presses Stop. ctx is
// this phase's, which *this function* cancels to shut the streams down — so the two are
// kept apart: asking ctx whether the test was cancelled would always say yes by then, and
// every failure would be reported as "context canceled" whatever really happened.
ctx, stop := context.WithCancel(parent)
defer stop()
var moved atomic.Int64
var firstErr error
var errOnce sync.Once
var alive atomic.Int32
alive.Store(int32(streams))
dead := make(chan struct{})
var deadOnce sync.Once
for i := 0; i < streams; i++ {
go func() {
defer func() {
if alive.Add(-1) == 0 {
deadOnce.Do(func() { close(dead) })
}
}()
tries := 0
for ctx.Err() == nil && moved.Load() < phaseBudget {
var err error
if dir == "download" {
err = stream(ctx, client, &moved)
} else {
err = upstream(ctx, client, &moved)
}
if err == nil {
tries = 0
continue
}
if ctx.Err() != nil {
return
}
errOnce.Do(func() { firstErr = err })
if tries++; tries > 2 { // refused three times: this stream is finished
return
}
select {
case <-time.After(400 * time.Millisecond):
case <-ctx.Done():
return
}
}
}()
}
// the ramp, measured and thrown away
select {
case <-time.After(warmUp):
case <-dead:
case <-ctx.Done():
}
mark, at := moved.Load(), time.Now()
last, lastAt := mark, at // the last time bytes were still arriving
deadline := at.Add(forHow)
tick := time.NewTicker(200 * time.Millisecond)
defer tick.Stop()
for time.Now().Before(deadline) {
select {
case <-tick.C:
case <-dead:
case <-ctx.Done():
}
if parent.Err() != nil {
break
}
now := moved.Load()
if now > last {
last, lastAt = now, time.Now()
}
// enough has moved to know, and it has been long enough to mean it
if (now-mark >= measureBytes && time.Since(at) >= minWindow) || now >= phaseBudget {
break
}
if secs := time.Since(at).Seconds(); secs > 0 {
live := mbps(now-mark, secs)
part := math.Min(1, secs/forHow.Seconds())
t.set(func(r *Run) { r.Mbps, r.Bytes, r.Done = live, now, from+(to-from)*part })
}
// every stream has given up and nothing more is coming
if alive.Load() == 0 {
break
}
}
total, secs := last-mark, lastAt.Sub(at).Seconds()
stop()
if total <= 0 {
switch {
case firstErr != nil: // what the far end said explains it better than anything else
return 0, moved.Load(), "", firstErr
case parent.Err() != nil:
return 0, moved.Load(), "", parent.Err()
}
return 0, moved.Load(), "", fmt.Errorf("nothing moved in the %s phase", dir)
}
if secs < 0.1 { // too short a window to divide by
return 0, moved.Load(), "", fmt.Errorf("the %s phase was over before it could be measured", dir)
}
note := ""
if firstErr != nil {
note = fmt.Sprintf("the %s phase was cut short: %s", dir, firstErr)
}
return mbps(total, secs), moved.Load(), note, nil
}
// join puts two notes together without leaving an empty one dangling.
func join(a, b string) string {
switch {
case a == "":
return b
case b == "":
return a
}
return a + "; " + b
}
// stream fetches one chunk, counting bytes as they arrive.
func stream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf(downURL, downChunk), nil)
if err != nil {
return err
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return answered(resp.StatusCode, resp.Status)
}
buf := make([]byte, 64<<10)
for {
got, err := resp.Body.Read(buf)
if got > 0 {
n.Add(int64(got))
}
if err != nil {
if err == io.EOF {
return nil
}
return err
}
}
}
// upstream posts one chunk of nothing in particular, counting bytes as they go out.
func upstream(ctx context.Context, client *http.Client, n *atomic.Int64) error {
body := &zeros{left: upChunk, n: n}
req, err := http.NewRequestWithContext(ctx, "POST", upURL, body)
if err != nil {
return err
}
req.ContentLength = upChunk
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return err
}
_, _ = io.Copy(io.Discard, resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 && resp.StatusCode != 204 {
return answered(resp.StatusCode, resp.Status)
}
return nil
}
// answered turns the far end's refusal into something worth reading. It rate-limits by
// volume: enough testing in a short while and it stops serving the big transfers, which is
// the only failure of this test anyone here is likely to meet.
func answered(code int, status string) error {
if code == 429 || code == 403 {
return errors.New("the speed endpoint is rate-limiting this connection — it does that after a lot of testing; leave it a few minutes")
}
return fmt.Errorf("the speed endpoint answered %s", status)
}
// zeros is a request body of a fixed size that costs nothing to produce, counting itself as
// it is read out onto the wire.
type zeros struct {
left int
n *atomic.Int64
}
func (z *zeros) Read(p []byte) (int, error) {
if z.left <= 0 {
return 0, io.EOF
}
if len(p) > z.left {
p = p[:z.left]
}
for i := range p {
p[i] = 0
}
z.left -= len(p)
z.n.Add(int64(len(p)))
return len(p), nil
}
func newTransport(streams int) *http.Transport {
return &http.Transport{
Proxy: http.ProxyFromEnvironment,
DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
TLSHandshakeTimeout: 10 * time.Second,
MaxIdleConns: streams * 2,
MaxIdleConnsPerHost: streams * 2,
// An empty map turns HTTP/2 off: see the note at the top of this file.
TLSNextProto: map[string]func(string, *tls.Conn) http.RoundTripper{},
}
}
// probeOutside asks the far end who it thinks we are. One small request.
func probeOutside() Outside {
tr := newTransport(1)
defer tr.CloseIdleConnections()
client := &http.Client{Transport: tr, Timeout: 15 * time.Second}
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", traceURL, nil)
if err != nil {
return Outside{At: time.Now(), Error: err.Error()}
}
resp, err := client.Do(req)
if err != nil {
return Outside{At: time.Now(), Error: shortErr(err)}
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
if resp.StatusCode != 200 {
return Outside{At: time.Now(), Error: "the trace endpoint answered " + resp.Status}
}
o := Outside{At: time.Now()}
for _, line := range strings.Split(string(body), "\n") {
k, v, ok := strings.Cut(line, "=")
if !ok {
continue
}
switch k {
case "ip":
o.IP = v
case "colo":
o.Colo = v
case "loc":
o.Country = v
}
}
if o.IP == "" {
o.Error = "the trace endpoint said nothing about this connection"
}
return o
}
// shortErr keeps the part of a network error a person can act on.
func shortErr(err error) string {
s := err.Error()
if i := strings.LastIndex(s, ": "); i > 0 && len(s)-i < 60 {
return s[i+2:]
}
return s
}
func mbps(bytes int64, secs float64) float64 {
return round(float64(bytes)*8/secs/1e6, 1)
}
func best(v []float64) float64 {
m := v[0]
for _, x := range v {
if x < m {
m = x
}
}
return round(m, 1)
}
// jitter is how much the line wobbles: the average change from one round trip to the next,
// which is the usual definition and, unlike a standard deviation, is not run away with by a
// single slow probe.
func jitter(v []float64) float64 {
if len(v) < 2 {
return 0
}
var sum float64
for i := 1; i < len(v); i++ {
sum += math.Abs(v[i] - v[i-1])
}
return round(sum/float64(len(v)-1), 1)
}
func round(v float64, places int) float64 {
p := math.Pow(10, float64(places))
return math.Round(v*p) / p
}
// Mbits renders a rate the way the line is sold, for a log line.
func Mbits(v float64) string { return strconv.FormatFloat(v, 'f', 1, 64) + " Mbps" }