// 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 }