Files
hamlab/internal/vpn/vpn.go
T
Mitchell-JohnsonandClaude Opus 5 32fdbf169a Fix the blank VPN tab, and stop one page taking the console with it
On a machine with wireguard-tools installed but no tunnel up — core, which is
the machine the console opens on — the tunnel list was a nil slice, which is
null in JSON, and the page took its length. That throws, React unmounts the
whole tree, and what is left is a blank window with nothing to go on.

Two fixes, because either alone leaves the failure possible. The parse returns
an empty list rather than nil, with a test that pins the marshalled shape; and
the page treats a missing list as an empty one, so a node still running an older
build cannot blank the console either.

And the reason a small mistake had such a large blast radius: nothing caught it.
Each page now renders inside a boundary, so a fault costs that page and says
what it was, while the rail, the switcher and every other tab carry on. The
terminals are deliberately left outside it — they hold live WebSockets, and
remounting them to recover from somebody else's error would drop the sessions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 22:18:59 -07:00

366 lines
12 KiB
Go

// 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, absent when there has never been one — a peer
// that is configured but has not connected since this tunnel came up. A pointer because
// omitempty does nothing for a zero time.Time, and a peer that has never been heard from
// would otherwise be reported as having last spoken in the year one.
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 != nil && 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 ha, hb := shookAt(pa), shookAt(pb); !ha.Equal(hb) {
return ha.After(hb)
}
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 {
// An empty list, never nil: this is JSON to a browser that will take its length, and a
// null there is a blank page rather than "no tunnels".
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 {
at := time.Unix(hs, 0)
p.Handshake = &at
}
tunnels[i].Peers = append(tunnels[i].Peers, p)
}
return tunnels
}
// shookAt is a peer's last handshake, or the zero time when there has never been one.
func shookAt(p Peer) time.Time {
if p.Handshake == nil {
return time.Time{}
}
return *p.Handshake
}
// 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 {
text, err := readMaybeRoot(path)
if err != nil {
continue
}
sc := bufio.NewScanner(strings.NewReader(text))
// 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 = ""
}
}
}
return out
}
// readMaybeRoot reads a file, falling back to root when it is not ours to read — which
// /etc/wireguard always is, since it holds private keys. Only the comments and public keys in
// it are looked at; nothing else in the file is kept or returned.
func readMaybeRoot(path string) (string, error) {
b, err := os.ReadFile(path)
if err == nil {
return string(b), nil
}
if !os.IsPermission(err) || !priv.Available() {
return "", err
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
args := priv.Wrap("cat", path)
out, cerr := exec.CommandContext(ctx, args[0], args[1:]...).Output()
if cerr != nil {
return "", cerr
}
return string(out), nil
}
// 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
}