diff --git a/PLAN.md b/PLAN.md index 4bcc5f5..ed7c4aa 100644 --- a/PLAN.md +++ b/PLAN.md @@ -629,6 +629,30 @@ Decisions worth keeping: history is the obvious next step and deliberately not built yet — the cron shape for it already exists twice over (ZFS snapshots, Docker image updates). +## VPN tab (2026-09-27) + +A **VPN** tab beside Network, per machine: every WireGuard tunnel on the machine in the +switcher, and the devices on it — name, address, last handshake, where it connects from, and +what it has moved. + +- **Per machine is the point, not a limitation.** The tunnel people dial into and a tunnel a + machine dials out through are on different boxes here (`gate` and `hamtv`), and a combined + view would invite exactly the question "which box is this on". +- **The kernel is the truth, the config only supplies names.** `wg show all dump` has the + handshakes and counters and knows nothing but public keys; names come from the `# comment` + above a peer in a wg-quick file, or a UCI section's `description` on OpenWrt. Both are read + only to label what the kernel already said. +- **Server or client is worked out, not declared.** A peer allowed `0.0.0.0/0` that has an + endpoint is a way out, so a tunnel holding one is this machine dialling out; a tunnel with a + listen port and peers is one being dialled. Anything else says neither rather than guessing. +- **"Online" is a handshake in the last three minutes**, and the tab says so. WireGuard is + silent when idle; there is no connection to be in, and a tab claiming otherwise would be + lying twice a day. +- **Read-only, and no secrets.** Private keys are in the second column of the dump and are + read past; pre-shared keys are noted as present and never read. Adding or editing peers is + not here: it means generating keys and writing the machine's network configuration, which is + a bigger thing than this tab, and a bad thing to get half right. + ## To do - **Grow the Files tab until copyparty can go** — asked for 2026-09-23 as two items (Files diff --git a/README.md b/README.md index a075d1f..77e18ad 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ What adapts to the host, rather than assuming Debian: | Services / timers | systemd of any age (JSON output where it exists, the plain table before v246); a clear message on hosts without systemd | | Disks | `lsblk`, or `/sys/block` when it's missing (busybox) or too old; SMART and ZFS only when their tools are there | | Network | `/proc` and `/sys` for the interfaces, addresses, link speed and default route. The speed test needs no tool installed: it measures against Cloudflare's public speed endpoints, so it works the same on Debian and on the OpenWrt router | +| VPN | WireGuard tunnels and their peers, from `wg show` where wireguard-tools is installed; peer names from wg-quick `.conf` comments or OpenWrt UCI descriptions. Read-only, and it never reads a private or pre-shared key | | Dashboard | `/proc` and `/sys` only: hwmon or thermal-zone temps, fans, NICs, disks, mounts; empty sections where the hardware has nothing to report. UPS from whichever of `pwrstat`, NUT (`upsc`) or `apcupsd` is installed | | Listen address | bound with `IP_FREEBIND`, so a LAN address that isn't up yet at boot doesn't fail the start | | Self-update | the new build must run and load this host's config before it replaces the old one; atomic rename; the daemon then re-execs itself, so it restarts the same way under systemd, OpenRC or nothing; `hamlab.prev` kept | diff --git a/internal/api/server.go b/internal/api/server.go index c2c4388..7e71ef5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -281,6 +281,7 @@ func (s *Server) routes() *http.ServeMux { m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod)) m.HandleFunc("GET /api/hardware", s.auth(s.hardwareReport)) m.HandleFunc("GET /api/network", s.auth(s.networkOverview)) + m.HandleFunc("GET /api/vpn", s.auth(s.vpnReport)) m.HandleFunc("GET /api/network/speedtest", s.auth(s.speedtestState)) m.HandleFunc("POST /api/network/speedtest", s.auth(s.speedtestStart)) m.HandleFunc("POST /api/network/speedtest/cancel", s.auth(s.speedtestCancel)) diff --git a/internal/api/vpn.go b/internal/api/vpn.go new file mode 100644 index 0000000..0ab2472 --- /dev/null +++ b/internal/api/vpn.go @@ -0,0 +1,16 @@ +package api + +// The VPN tab: this machine's WireGuard tunnels and who is on them. Read-only, and per +// machine like every other tab — the tunnels shown are the ones on the machine in the +// switcher, which is the only honest way to show them: the inbound server and an outbound +// tunnel are different machines here. + +import ( + "net/http" + + "hamlab/internal/vpn" +) + +func (s *Server) vpnReport(w http.ResponseWriter, r *http.Request) { + jsonOK(w, 200, vpn.Look(r.Context())) +} diff --git a/internal/vpn/vpn.go b/internal/vpn/vpn.go new file mode 100644 index 0000000..d496637 --- /dev/null +++ b/internal/vpn/vpn.go @@ -0,0 +1,332 @@ +// Package vpn reads this machine's WireGuard tunnels: the interfaces, and the peers on them +// with names put back on. +// +// The live state comes from `wg show all dump`, which is the kernel's own answer and the only +// place handshakes and byte counts exist. The kernel knows nothing about names, though — a +// peer there is a public key — so the configuration is read alongside it purely to label +// them: `# comments` in a wg-quick .conf, or the `description` of a UCI peer section on +// OpenWrt. Everything here is read-only, and no private or pre-shared key is ever read, let +// alone returned. +package vpn + +import ( + "bufio" + "context" + "net" + "os" + "os/exec" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "hamlab/internal/priv" +) + +// Peer is one end of a tunnel: a device that dials in, or the server this machine dials out +// to. Which it is depends on the tunnel; see Tunnel.Kind. +type Peer struct { + // Name is what the configuration calls it, where it says at all. The kernel has no idea. + Name string `json:"name,omitempty"` + PublicKey string `json:"public_key"` + // Endpoint is where its packets come from, empty until it has been heard from. + Endpoint string `json:"endpoint,omitempty"` + AllowedIPs []string `json:"allowed_ips"` + // Handshake is the last completed handshake. Zero means never: a peer that is configured + // but has not connected since this tunnel came up. + Handshake time.Time `json:"handshake,omitempty"` + // Online is a handshake inside the last three minutes. WireGuard is silent when idle, so + // this means "was talking recently", not "is connected" — there is no such thing here. + Online bool `json:"online"` + Rx int64 `json:"rx"` + Tx int64 `json:"tx"` + Keepalive int `json:"keepalive,omitempty"` + // PSK notes that a pre-shared key is in use, without the key itself going anywhere. + PSK bool `json:"psk,omitempty"` +} + +// Tunnel is one WireGuard interface. +type Tunnel struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` + // Kind is "server" when peers dial in to this machine, "client" when this machine dials + // out through a peer, and "" when it is too new or too odd to tell. It is worked out from + // the shape of the peers, not from anything WireGuard states: a peer that is routed + // everything and has an endpoint of its own is an exit, and a tunnel with one of those is + // this machine going out through it. + Kind string `json:"kind"` + ListenPort int `json:"listen_port,omitempty"` + FwMark string `json:"fwmark,omitempty"` + Addrs []string `json:"addrs"` + MTU int `json:"mtu,omitempty"` + Peers []Peer `json:"peers"` + // Exit is the peer this machine goes out through, for a client tunnel. + Exit string `json:"exit,omitempty"` +} + +// Report is every tunnel on this machine, and why there are none when there are none. +type Report struct { + At time.Time `json:"at"` + Tunnels []Tunnel `json:"tunnels"` + // Tool is false when wireguard-tools is not installed, Root false when the daemon cannot + // reach root — either one means the peers cannot be read, and the tab should say which. + Tool bool `json:"tool"` + Root bool `json:"root"` + Note string `json:"note,omitempty"` +} + +const onlineWithin = 3 * time.Minute + +// Look reads the tunnels on this machine. +func Look(ctx context.Context) Report { + r := Report{At: time.Now(), Tunnels: []Tunnel{}, Root: priv.Available()} + if _, err := exec.LookPath("wg"); err != nil { + r.Note = "wireguard-tools is not installed here, so there is nothing to read" + return r + } + r.Tool = true + args := []string{"wg", "show", "all", "dump"} + if priv.Available() { + args = priv.Wrap(args...) + } + cmd := exec.CommandContext(ctx, args[0], args[1:]...) + out, err := cmd.Output() + if err != nil { + if !r.Root { + r.Note = "reading WireGuard needs root, and this daemon has no way to it" + } else { + r.Note = "wg show failed: " + strings.TrimSpace(err.Error()) + } + return r + } + r.Tunnels = parseDump(string(out)) + names := peerNames() + for i := range r.Tunnels { + t := &r.Tunnels[i] + t.Addrs, t.MTU = ifaceAddrs(t.Name) + for j := range t.Peers { + p := &t.Peers[j] + p.Name = names[p.PublicKey] + p.Online = !p.Handshake.IsZero() && time.Since(p.Handshake) < onlineWithin + } + t.Kind, t.Exit = classify(*t) + sort.SliceStable(t.Peers, func(a, b int) bool { + pa, pb := t.Peers[a], t.Peers[b] + if pa.Online != pb.Online { + return pa.Online + } + if !pa.Handshake.Equal(pb.Handshake) { + return pa.Handshake.After(pb.Handshake) + } + return pa.Name < pb.Name + }) + } + if len(r.Tunnels) == 0 { + r.Note = "no WireGuard interfaces are up on this machine" + } + return r +} + +// parseDump reads `wg show all dump`. The first line of each interface is the interface +// itself, the rest are its peers; every line is tab-separated and positional. +// +// interface: name private-key public-key listen-port fwmark +// peer: name public-key psk endpoint allowed-ips handshake rx tx keepalive +// +// The private key is in the second column and is read past without being kept. +func parseDump(out string) []Tunnel { + var tunnels []Tunnel + byName := map[string]int{} + sc := bufio.NewScanner(strings.NewReader(out)) + sc.Buffer(make([]byte, 0, 64<<10), 1<<20) + for sc.Scan() { + f := strings.Split(sc.Text(), "\t") + if len(f) < 5 { + continue + } + name := f[0] + if len(f) == 5 { // an interface line + port, _ := strconv.Atoi(f[3]) + byName[name] = len(tunnels) + tunnels = append(tunnels, Tunnel{Name: name, PublicKey: f[2], ListenPort: port, FwMark: off(f[4]), Peers: []Peer{}}) + continue + } + if len(f) < 9 { + continue + } + i, ok := byName[name] + if !ok { + continue + } + hs, _ := strconv.ParseInt(f[5], 10, 64) + rx, _ := strconv.ParseInt(f[6], 10, 64) + tx, _ := strconv.ParseInt(f[7], 10, 64) + ka, _ := strconv.Atoi(f[8]) + p := Peer{PublicKey: f[1], PSK: f[2] != "(none)", Endpoint: off(f[3]), Rx: rx, Tx: tx, Keepalive: ka} + if f[4] != "(none)" { + p.AllowedIPs = strings.Split(f[4], ",") + } + if hs > 0 { + p.Handshake = time.Unix(hs, 0) + } + tunnels[i].Peers = append(tunnels[i].Peers, p) + } + return tunnels +} + +// off turns wg's placeholders into an empty string. +func off(v string) string { + if v == "(none)" || v == "off" { + return "" + } + return v +} + +// classify decides whether this machine is the one being dialled or the one dialling. A peer +// that is allowed every address and has an endpoint is a way out to the internet; a tunnel +// with one of those is this machine using it. Otherwise, if anything can reach us here, it is +// a server. +func classify(t Tunnel) (kind, exit string) { + for _, p := range t.Peers { + for _, a := range p.AllowedIPs { + if a == "0.0.0.0/0" || a == "::/0" { + if p.Endpoint != "" { + name := p.Name + if name == "" { + name = p.Endpoint + } + return "client", name + } + } + } + } + if t.ListenPort > 0 && len(t.Peers) > 0 { + return "server", "" + } + return "", "" +} + +// ifaceAddrs is what the interface carries, and its MTU. +func ifaceAddrs(name string) ([]string, int) { + addrs := []string{} + mtu := 0 + ifi, err := net.InterfaceByName(name) + if err != nil { + return addrs, mtu + } + mtu = ifi.MTU + got, err := ifi.Addrs() + if err != nil { + return addrs, mtu + } + for _, a := range got { + addrs = append(addrs, a.String()) + } + return addrs, mtu +} + +// ---- names ---- + +// peerNames maps a public key to what the configuration calls that peer. Two shapes are +// understood, which between them cover every machine here: wg-quick's .conf files, where the +// name is a comment, and OpenWrt's UCI, where it is the peer section's description. +func peerNames() map[string]string { + out := map[string]string{} + for k, v := range confNames() { + out[k] = v + } + for k, v := range uciNames() { // UCI wins: on OpenWrt it is the live configuration + out[k] = v + } + return out +} + +// confNames reads /etc/wireguard/*.conf. In a wg-quick file a peer is named by the comment +// that introduces it, which is the convention every tool that writes these files follows: +// +// # phone +// [Peer] +// PublicKey = … +// +// confDir is where wg-quick keeps its files; a variable so the test can point it elsewhere. +var confDir = "/etc/wireguard" + +func confNames() map[string]string { + out := map[string]string{} + files, _ := filepath.Glob(filepath.Join(confDir, "*.conf")) + for _, path := range files { + f, err := os.Open(path) + if err != nil { + continue // unreadable without root, which is the normal case; the kernel still has the keys + } + sc := bufio.NewScanner(f) + // last is the most recent comment line; when a [Peer] block opens, that comment becomes + // its pending name. Keeping the two apart is what lets each block take the comment that + // introduces it and ignore any comment written inside it. + last, pending := "", "" + inPeer := false + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + switch { + case strings.EqualFold(line, "[Peer]"): + inPeer, pending, last = true, last, "" + case strings.HasPrefix(line, "["): + inPeer, pending, last = false, "", "" + case strings.HasPrefix(line, "#"): + if c := strings.TrimSpace(strings.TrimLeft(line, "#")); c != "" { + last = c + } + case inPeer && strings.HasPrefix(line, "PublicKey"): + // The separator is the first '='; the key's own base64 padding is the rest of + // the line, so everything after that first one is the key. + if _, v, ok := strings.Cut(line, "="); ok && pending != "" { + out[strings.TrimSpace(v)] = pending + } + pending = "" + } + } + f.Close() + } + return out +} + +// uciNames reads OpenWrt's configuration: each peer is a section with a description and a +// public key. `uci show` is used rather than the file, so includes and overrides are applied. +func uciNames() map[string]string { + out := map[string]string{} + if _, err := exec.LookPath("uci"); err != nil { + return out + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + b, err := exec.CommandContext(ctx, "uci", "-q", "show", "network").Output() + if err != nil { + return out + } + desc, key := map[string]string{}, map[string]string{} + for _, line := range strings.Split(string(b), "\n") { + lhs, rhs, ok := strings.Cut(line, "=") + if !ok || !strings.HasPrefix(lhs, "network.") { + continue + } + parts := strings.Split(strings.TrimPrefix(lhs, "network."), ".") + if len(parts) != 2 { + continue + } + section, option := parts[0], parts[1] + v := strings.Trim(rhs, "'") + switch option { + case "description": + desc[section] = v + case "public_key": + key[section] = v + } + } + for section, k := range key { + if d := desc[section]; d != "" { + out[k] = d + } + } + return out +} diff --git a/internal/vpn/vpn_test.go b/internal/vpn/vpn_test.go new file mode 100644 index 0000000..9433577 --- /dev/null +++ b/internal/vpn/vpn_test.go @@ -0,0 +1,151 @@ +package vpn + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +// Real output of `wg show all dump`, from the two machines here: gate, where devices dial in, +// and hamtv, which dials out through Mullvad. Keys are the real public ones, which are public. +const gateDump = "wg0\tPRIVATEKEYWOULDBEHERE=\t4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=\t51820\toff\n" + + "wg0\tNeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=\t3KWsu0mdVb2l5B/j0qHE03yE9LXi3FJ1WAjWMuxaLAw=\t10.0.0.33:63573\t172.21.46.3/32\t1790565744\t134192164\t415977404\toff\n" + + "wg0\twUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=\t4Pd5u2t6U9EdHlFlneg+hMr0G2v+IvoE/EqGfFdDPI8=\t(none)\t172.21.46.2/32\t0\t0\t0\toff\n" + +const hamtvDump = "wg0\tPRIVATEKEYWOULDBEHERE=\tF/rgcHi+ZiaWPoSQ9iPcldIUbbMotE1hwSB/74Y9Qi4=\t39281\toff\n" + + "wg0\tzqsfGglzJPY657WMRxf/S4omG7+ZkSDIpDq+ggbc9yo=\t(none)\t23.234.72.2:51820\t0.0.0.0/0,::/0\t1790571246\t18206675520\t2687837240\t25\n" + +func TestParseServerDump(t *testing.T) { + ts := parseDump(gateDump) + if len(ts) != 1 { + t.Fatalf("got %d tunnels, want 1", len(ts)) + } + w := ts[0] + if w.Name != "wg0" || w.ListenPort != 51820 { + t.Errorf("interface = %s:%d, want wg0:51820", w.Name, w.ListenPort) + } + if w.PublicKey != "4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=" { + t.Errorf("public key = %q; the third column is the public one", w.PublicKey) + } + // The private key is column two of the interface line and must not appear anywhere. + if w.PublicKey == "PRIVATEKEYWOULDBEHERE=" || w.FwMark == "PRIVATEKEYWOULDBEHERE=" { + t.Error("the private key was kept") + } + if len(w.Peers) != 2 { + t.Fatalf("got %d peers, want 2", len(w.Peers)) + } + p := w.Peers[0] + if p.PublicKey != "NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=" || p.Endpoint != "10.0.0.33:63573" { + t.Errorf("first peer = %+v", p) + } + if !p.PSK { + t.Error("first peer has a pre-shared key and should say so") + } + if got := p.AllowedIPs; len(got) != 1 || got[0] != "172.21.46.3/32" { + t.Errorf("allowed ips = %v", got) + } + if p.Rx != 134192164 || p.Tx != 415977404 { + t.Errorf("counters = %d/%d", p.Rx, p.Tx) + } + if p.Handshake.Unix() != 1790565744 { + t.Errorf("handshake = %v", p.Handshake) + } + // The second peer has never connected: no endpoint, no handshake, nothing moved. + if q := w.Peers[1]; q.Endpoint != "" || !q.Handshake.IsZero() || q.Rx != 0 { + t.Errorf("second peer = %+v, want one that has never been heard from", q) + } + if kind, exit := classify(w); kind != "server" || exit != "" { + t.Errorf("classify = %q/%q, want server", kind, exit) + } +} + +func TestParseClientDump(t *testing.T) { + ts := parseDump(hamtvDump) + if len(ts) != 1 || len(ts[0].Peers) != 1 { + t.Fatalf("got %d tunnels", len(ts)) + } + w := ts[0] + p := w.Peers[0] + if len(p.AllowedIPs) != 2 || p.AllowedIPs[0] != "0.0.0.0/0" { + t.Errorf("allowed ips = %v, want everything", p.AllowedIPs) + } + if p.Keepalive != 25 { + t.Errorf("keepalive = %d, want 25", p.Keepalive) + } + if p.PSK { + t.Error("this peer has no pre-shared key") + } + // It routes everything and has an endpoint, so this machine goes out through it. + kind, exit := classify(w) + if kind != "client" || exit != "23.234.72.2:51820" { + t.Errorf("classify = %q/%q, want client through the endpoint", kind, exit) + } +} + +// A tunnel that is up but has no peers at all is neither, and must not be guessed at. +func TestClassifyUndecided(t *testing.T) { + if kind, _ := classify(Tunnel{Name: "wg0", ListenPort: 51820}); kind != "" { + t.Errorf("classify with no peers = %q, want no opinion", kind) + } +} + +// Peers are named by the comment above them in a wg-quick file. The public key's own base64 +// padding is an '=' too, which is the thing to get wrong. +func TestConfNames(t *testing.T) { + dir := t.TempDir() + conf := `[Interface] +Address = 172.21.46.1/24 +PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET= +ListenPort = 51820 + +# phone +[Peer] +PublicKey = NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU= +AllowedIPs = 172.21.46.3/32 + +# the laptop +[Peer] +PublicKey = wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg= +AllowedIPs = 172.21.46.2/32 +` + if err := os.WriteFile(filepath.Join(dir, "wg0.conf"), []byte(conf), 0o600); err != nil { + t.Fatal(err) + } + old := confDir + confDir = dir + defer func() { confDir = old }() + + names := confNames() + if got := names["NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU="]; got != "phone" { + t.Errorf("first peer named %q, want phone", got) + } + if got := names["wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg="]; got != "the laptop" { + t.Errorf("second peer named %q, want the laptop", got) + } + for k := range names { + if k == "SECRETSECRETSECRETSECRETSECRETSECRETSECRET=" { + t.Error("the private key was read as if it were a peer") + } + } + if len(names) != 2 { + t.Errorf("%d names, want 2: %v", len(names), names) + } +} + +func TestOnlineIsRecentHandshake(t *testing.T) { + for _, tc := range []struct { + name string + hs time.Time + want bool + }{ + {"just now", time.Now().Add(-30 * time.Second), true}, + {"a while back", time.Now().Add(-10 * time.Minute), false}, + {"never", time.Time{}, false}, + } { + got := !tc.hs.IsZero() && time.Since(tc.hs) < onlineWithin + if got != tc.want { + t.Errorf("%s: online = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/web/src/Network.tsx b/web/src/Network.tsx index de02c80..dee3b9d 100644 --- a/web/src/Network.tsx +++ b/web/src/Network.tsx @@ -16,7 +16,13 @@ import Sparkline from './Sparkline' const scenery = (f: Iface) => !f.default && (/^br-[0-9a-f]{12}$/.test(f.name) || /^(veth|docker)/.test(f.name) || f.kind === 'loopback') -const KIND: Record = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi' } +const KIND: Record = { ethernet: 'wired', wifi: 'Wi-Fi', bridge: 'bridge', bond: 'bond', loopback: 'loopback', virtual: 'virtual', vlan: 'VLAN', wlan: 'Wi-Fi', wireguard: 'VPN tunnel', none: 'tunnel' } + +// A WireGuard interface is listed here because it is an interface and carries traffic, but +// everything about it that is worth knowing — who is on it, when they last spoke — belongs to +// the VPN tab, and is only there. +const isTunnel = (f: Iface) => f.kind === 'wireguard' || /^(wg|tun|tap)[0-9]/.test(f.name) +const goVPN = () => window.dispatchEvent(new CustomEvent('hamlab:view', { detail: 'vpn' })) // Where the bytes came from. The usual source is anycast and its edge is the interesting // part; a fallback source is somewhere fixed, and then *which* one is the interesting part, @@ -173,6 +179,7 @@ export default function Network() { {f.name} {f.default && internet} {KIND[f.kind] ?? f.kind} + {isTunnel(f) && } {!f.up ? down : !f.carrier && no link} {typeof rx === 'number' ? `↓ ${fmtValue(rx, 'B/s')}` : ''} {typeof tx === 'number' ? `↑ ${fmtValue(tx, 'B/s')}` : ''} diff --git a/web/src/Shell.tsx b/web/src/Shell.tsx index 8d52b40..ad3fc11 100644 --- a/web/src/Shell.tsx +++ b/web/src/Shell.tsx @@ -13,6 +13,7 @@ import Updates from './Updates' import Disks from './Disks' import Hardware from './Hardware' import Network from './Network' +import VPN from './VPN' import ZFS from './ZFS' import KVM from './KVM' import Files from './Files' @@ -60,6 +61,14 @@ const GlobeIcon = ( ) +// A padlock-ish shield: the tab is about who may come in, and a keyhole reads at 14px where +// a padlock's shackle does not. +const ShieldIcon = ( + +) + const GROUPS: Group[] = [ { id: 'procs', label: 'Processes', icon: '▚' }, { id: 'terminal', label: 'Terminal', icon: '>_' }, @@ -68,6 +77,7 @@ const GROUPS: Group[] = [ { id: 'disks', label: 'Disks', icon: '▤' }, { id: 'files', label: 'Files', icon: FolderIcon }, { id: 'network', label: 'Network', icon: GlobeIcon }, + { id: 'vpn', label: 'VPN', icon: ShieldIcon }, { id: 'usage', label: 'Usage', icon: '▧' }, { id: 'zfs', label: 'ZFS', icon: '⛁' }, { id: 'docker', label: 'Docker', icon: DockerIcon }, @@ -81,8 +91,8 @@ const GROUPS: Group[] = [ { id: 'settings', label: 'Settings', icon: '⚙' }, ] -type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' -const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] +type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings' +const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings'] const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts'] // URL hash is the source of truth for where you are: #//. Back and @@ -139,6 +149,18 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: useEffect(() => { reload() }, [reload]) + // One page sending you to another — the Network tab handing a tunnel over to the VPN tab. + // A custom event rather than a hash assignment, because assigning location.hash does not + // reliably raise popstate, which is what this shell navigates by. + useEffect(() => { + const h = (e: Event) => { + const id = (e as CustomEvent).detail + if (VIEWS.includes(id as View)) { setView(id as View); setDrawer(false) } + } + window.addEventListener('hamlab:view', h) + return () => window.removeEventListener('hamlab:view', h) + }, []) + // A shell opened inside a container from the Docker tab is a terminal like any other: // pick it up, show the Terminal tab, and make it the active one. useEffect(() => { @@ -313,6 +335,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut: {view === 'disks' && } {view === 'hardware' && } {view === 'network' && } + {view === 'vpn' && } {view === 'zfs' && } {view === 'files' && } {termView && {machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version} up {fmtUptime(me.uptime)} - {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} + {view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''} ) diff --git a/web/src/VPN.tsx b/web/src/VPN.tsx new file mode 100644 index 0000000..57fb270 --- /dev/null +++ b/web/src/VPN.tsx @@ -0,0 +1,113 @@ +// VPN: this machine's WireGuard tunnels, and the devices on them. +// +// Per machine, like every other tab, which here is the whole point: the tunnel people dial +// into and a tunnel this machine dials out through are different machines, and showing them +// together would invite the question "which box is this actually on". +import { useCallback, useEffect, useState } from 'react' +import { api, VPNReport, VPNPeer, VPNTunnel } from './api' +import { fmtBytes } from './fmt' +import { Loading } from './ui' +import { useRefreshMs } from './prefs' + +// WireGuard says nothing when it has nothing to say, so "online" is a handshake in the last +// few minutes rather than a connection — there is no connection to have. +const ago = (iso?: string) => { + if (!iso) return 'never' + const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000) + if (s < 60) return `${Math.floor(s)}s ago` + if (s < 3600) return `${Math.floor(s / 60)} min ago` + if (s < 86400) return `${Math.floor(s / 3600)} h ago` + return `${Math.floor(s / 86400)} d ago` +} + +// A public key is long and nobody reads the middle of it; the ends are what you compare. +const shortKey = (k: string) => (k.length > 16 ? `${k.slice(0, 8)}…${k.slice(-6)}` : k) + +function Peer({ p, server }: { p: VPNPeer; server: boolean }) { + return ( +
+
+ + {p.name || unnamed} + {p.online && online} + {!p.handshake && never connected} + + {(p.allowed_ips ?? []).join(', ')} +
+
+
Last handshake{ago(p.handshake)}
+ {p.endpoint &&
{server ? 'Comes from' : 'Endpoint'}{p.endpoint}
} +
Moved↓ {fmtBytes(p.rx)} · ↑ {fmtBytes(p.tx)}
+ {!!p.keepalive &&
Keepaliveevery {p.keepalive}s
} +
Public key{shortKey(p.public_key)}
+ {p.psk &&
Pre-shared keyin use
} +
+
+ ) +} + +function Tunnel({ t }: { t: VPNTunnel }) { + const server = t.kind === 'server' + const online = t.peers.filter((p) => p.online).length + return ( + <> +
+

{t.name}

+ {server ? 'devices dial in' : t.kind === 'client' ? 'this machine dials out' : 'tunnel'} + + {server ? `${online} of ${t.peers.length} online` : t.exit ? `via ${t.exit}` : ''} +
+
+
+ {!!t.addrs?.length &&
Address{t.addrs.join(', ')}
} + {!!t.listen_port &&
Listening on{t.listen_port}/udp
} + {!!t.mtu &&
MTU{t.mtu}
} +
Public key{shortKey(t.public_key)}
+ {t.fwmark &&
Firewall mark{t.fwmark}
} +
+
+ {t.peers.map((p) => )} + {t.peers.length === 0 &&
No peers are configured on this tunnel.
} + + ) +} + +export default function VPN() { + const [d, setD] = useState(null) + const [err, setErr] = useState('') + const rate = useRefreshMs() + const load = useCallback(async () => { + try { setD(await api.vpn()); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } + }, []) + useEffect(() => { load() }, [load]) + // Handshakes and counters move; nothing here is expensive to read. + useEffect(() => { + const t = setInterval(() => { if (document.visibilityState === 'visible') load() }, Math.max(5000, rate)) + return () => clearInterval(t) + }, [load, rate]) + + if (!d) return
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the VPN tab. Update it from Machines." : err}
: }
+ + return ( +
+ {d.tunnels.length === 0 && ( + <> +

VPN

+
{d.note || 'Nothing here.'}
+
+ Tunnels are the machine's own, so look on the one that carries them — the switcher at the top of the rail. +
+
+ + )} + {d.tunnels.map((t) => )} + {d.tunnels.length > 0 && ( +
+ Read from the kernel with wg show; names come from the configuration beside it. + Online means a handshake in the last three minutes — WireGuard is silent when idle, so there is no connection to be in. + Private and pre-shared keys are never read. +
+ )} +
+ ) +} diff --git a/web/src/api.ts b/web/src/api.ts index 0d3c985..f578c78 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -189,6 +189,19 @@ export interface SpeedResult { export interface SpeedRun { started: string; running: boolean; phase: string; done: number; mbps: number; bytes: number; result?: SpeedResult } export interface NetworkInfo { report: NetReport; outside: Outside; run?: SpeedRun; history: SpeedResult[]; method: string } +// ---- the VPN tab ---- +// A peer is a device that dials in (on a server tunnel) or the way out (on a client one). +// `online` is a handshake in the last few minutes, not a connection: WireGuard has none. +export interface VPNPeer { + name?: string; public_key: string; endpoint?: string; allowed_ips: string[] + handshake?: string; online: boolean; rx: number; tx: number; keepalive?: number; psk?: boolean +} +export interface VPNTunnel { + name: string; public_key: string; kind: 'server' | 'client' | ''; listen_port?: number + fwmark?: string; addrs: string[]; mtu?: number; peers: VPNPeer[]; exit?: string +} +export interface VPNReport { at: string; tunnels: VPNTunnel[]; tool: boolean; root: boolean; note?: string } + export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean } export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string } export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number } @@ -303,6 +316,7 @@ export const api = { createReadToken: (name: string, sensors: string[] = []) => req<{ token: string; name: string }>('POST', '/api/tokens/read', { name, sensors }, true), hardware: (refresh = false) => req('GET', `/api/hardware${refresh ? '?refresh=1' : ''}`), network: () => req('GET', '/api/network'), + vpn: () => req('GET', '/api/vpn'), speedtest: () => req('GET', '/api/network/speedtest'), startSpeedtest: () => req('POST', '/api/network/speedtest'), cancelSpeedtest: () => req('POST', '/api/network/speedtest/cancel'),