Files
Mitchell-JohnsonandClaude Opus 5 8fb21ee95e Create, rename and remove the devices that dial in
On a tunnel this machine configures — today that means OpenWrt's UCI, which is
where the server here lives — the VPN tab can add a device, rename it and take
it away. A wg-quick host is reported as unmanaged and shows no buttons, because
half-supporting a second configuration format is worse than an honest refusal.

Adding generates the keypair and a pre-shared key on the machine, picks the
lowest free address in the tunnel's range, and hands back the complete client
profile with a QR code for the WireGuard apps. The private key in it is
generated for that one call and stored nowhere, so the profile is shown until
dismissed and then gone — the same bargain as any password this console makes.

Nothing restarts the network, which matters when the interface belongs to the
router the house is behind. `uci commit` writes the file and applies nothing;
`wg set` changes one peer on the live interface and touches nothing else.

The order of the two is chosen so they cannot disagree quietly. Adding writes
the configuration first and rolls it back if the live tunnel refuses, because a
device that works only after the next reboot is the worst of the three outcomes.
Removing does the live tunnel first, since that is the moment access actually
ends, and says plainly if the configuration half then fails.

Removing asks for the device's name to be typed back, at both ends. It is the
one action here that cannot be undone and it sits one tap from a list of devices
that all look alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 23:20:41 -07:00

373 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"`
// Section is the configuration section holding this peer, and the identifier anything that
// edits it uses. Empty when the peer is not in a configuration this can edit.
Section string `json:"section,omitempty"`
}
// Tunnel is one WireGuard interface.
type Tunnel struct {
Name string `json:"name"`
// Managed is whether devices on this tunnel can be added and removed from here, which
// today means its peers are configured through OpenWrt's UCI.
Managed bool `json:"managed"`
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)
t.Managed = Managed(ctx, 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
}