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>
366 lines
12 KiB
Go
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
|
|
}
|