Two faults in the VPN tab, both found by running it against the real machines. omitempty does nothing for a zero time.Time, so a peer that has never handshaken was serialised as the year one and the page would have shown mbp14 as last seen on 1 January 0001 instead of "never connected". The field is a pointer now, and a test pins the JSON. And peer names on a wg-quick host were never going to appear: /etc/wireguard is root-only because it holds private keys, and the file was being opened as the daemon's own user. It now falls back to reading it as root, the same way the kernel state beside it is already read. Only comments and public keys are taken from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
364 lines
12 KiB
Go
364 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 {
|
|
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 {
|
|
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
|
|
}
|