A VPN tab: the tunnels, and who is on them
Beside Network, per machine. Every WireGuard interface on the machine in the switcher, with its address, listen port and public key, and under it the peers: name, allowed addresses, last handshake, where they connect from, and what they have moved. Per machine is the point rather than a limitation. The tunnel devices dial into and a tunnel a machine dials out through are different boxes here, and putting them on one page would invite the question of which box you were looking at. The kernel is the truth and the configuration only supplies names: wg show all dump has the handshakes and the counters and knows nothing but public keys, so the # comment above a peer in a wg-quick file, or a UCI section's description on OpenWrt, is read alongside purely to label them. Whether a tunnel is dialled or dialling is worked out from the shape of its peers, and says neither when it cannot tell. Online means a handshake in the last three minutes, and the tab says so in as many words: WireGuard is silent when idle, so there is no connection to be in. Read-only, and no secrets leave: the private key is the second column of the dump and is read past, pre-shared keys are noted as present and never read. The Network tab keeps listing tunnels as the interfaces they are, and hands off to here for anything about them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
9c7848907b
commit
6f634bbe11
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user