Files
hamlab/internal/vpn/vpn_test.go
T
Mitchell-JohnsonandClaude Opus 5 6f634bbe11 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>
2026-09-27 21:59:30 -07:00

152 lines
5.1 KiB
Go

package vpn
import (
"os"
"path/filepath"
"testing"
"time"
)
// Real output of `wg show all dump`, from the two machines here: gate, where devices dial in,
// and hamtv, which dials out through Mullvad. Keys are the real public ones, which are public.
const gateDump = "wg0\tPRIVATEKEYWOULDBEHERE=\t4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=\t51820\toff\n" +
"wg0\tNeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=\t3KWsu0mdVb2l5B/j0qHE03yE9LXi3FJ1WAjWMuxaLAw=\t10.0.0.33:63573\t172.21.46.3/32\t1790565744\t134192164\t415977404\toff\n" +
"wg0\twUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=\t4Pd5u2t6U9EdHlFlneg+hMr0G2v+IvoE/EqGfFdDPI8=\t(none)\t172.21.46.2/32\t0\t0\t0\toff\n"
const hamtvDump = "wg0\tPRIVATEKEYWOULDBEHERE=\tF/rgcHi+ZiaWPoSQ9iPcldIUbbMotE1hwSB/74Y9Qi4=\t39281\toff\n" +
"wg0\tzqsfGglzJPY657WMRxf/S4omG7+ZkSDIpDq+ggbc9yo=\t(none)\t23.234.72.2:51820\t0.0.0.0/0,::/0\t1790571246\t18206675520\t2687837240\t25\n"
func TestParseServerDump(t *testing.T) {
ts := parseDump(gateDump)
if len(ts) != 1 {
t.Fatalf("got %d tunnels, want 1", len(ts))
}
w := ts[0]
if w.Name != "wg0" || w.ListenPort != 51820 {
t.Errorf("interface = %s:%d, want wg0:51820", w.Name, w.ListenPort)
}
if w.PublicKey != "4VwqgzHyelmVmNY+EANRWQggSSxvYYguJQMvUty9Sns=" {
t.Errorf("public key = %q; the third column is the public one", w.PublicKey)
}
// The private key is column two of the interface line and must not appear anywhere.
if w.PublicKey == "PRIVATEKEYWOULDBEHERE=" || w.FwMark == "PRIVATEKEYWOULDBEHERE=" {
t.Error("the private key was kept")
}
if len(w.Peers) != 2 {
t.Fatalf("got %d peers, want 2", len(w.Peers))
}
p := w.Peers[0]
if p.PublicKey != "NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=" || p.Endpoint != "10.0.0.33:63573" {
t.Errorf("first peer = %+v", p)
}
if !p.PSK {
t.Error("first peer has a pre-shared key and should say so")
}
if got := p.AllowedIPs; len(got) != 1 || got[0] != "172.21.46.3/32" {
t.Errorf("allowed ips = %v", got)
}
if p.Rx != 134192164 || p.Tx != 415977404 {
t.Errorf("counters = %d/%d", p.Rx, p.Tx)
}
if p.Handshake.Unix() != 1790565744 {
t.Errorf("handshake = %v", p.Handshake)
}
// The second peer has never connected: no endpoint, no handshake, nothing moved.
if q := w.Peers[1]; q.Endpoint != "" || !q.Handshake.IsZero() || q.Rx != 0 {
t.Errorf("second peer = %+v, want one that has never been heard from", q)
}
if kind, exit := classify(w); kind != "server" || exit != "" {
t.Errorf("classify = %q/%q, want server", kind, exit)
}
}
func TestParseClientDump(t *testing.T) {
ts := parseDump(hamtvDump)
if len(ts) != 1 || len(ts[0].Peers) != 1 {
t.Fatalf("got %d tunnels", len(ts))
}
w := ts[0]
p := w.Peers[0]
if len(p.AllowedIPs) != 2 || p.AllowedIPs[0] != "0.0.0.0/0" {
t.Errorf("allowed ips = %v, want everything", p.AllowedIPs)
}
if p.Keepalive != 25 {
t.Errorf("keepalive = %d, want 25", p.Keepalive)
}
if p.PSK {
t.Error("this peer has no pre-shared key")
}
// It routes everything and has an endpoint, so this machine goes out through it.
kind, exit := classify(w)
if kind != "client" || exit != "23.234.72.2:51820" {
t.Errorf("classify = %q/%q, want client through the endpoint", kind, exit)
}
}
// A tunnel that is up but has no peers at all is neither, and must not be guessed at.
func TestClassifyUndecided(t *testing.T) {
if kind, _ := classify(Tunnel{Name: "wg0", ListenPort: 51820}); kind != "" {
t.Errorf("classify with no peers = %q, want no opinion", kind)
}
}
// Peers are named by the comment above them in a wg-quick file. The public key's own base64
// padding is an '=' too, which is the thing to get wrong.
func TestConfNames(t *testing.T) {
dir := t.TempDir()
conf := `[Interface]
Address = 172.21.46.1/24
PrivateKey = SECRETSECRETSECRETSECRETSECRETSECRETSECRET=
ListenPort = 51820
# phone
[Peer]
PublicKey = NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU=
AllowedIPs = 172.21.46.3/32
# the laptop
[Peer]
PublicKey = wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg=
AllowedIPs = 172.21.46.2/32
`
if err := os.WriteFile(filepath.Join(dir, "wg0.conf"), []byte(conf), 0o600); err != nil {
t.Fatal(err)
}
old := confDir
confDir = dir
defer func() { confDir = old }()
names := confNames()
if got := names["NeWxo9wfqgipBQLuP676WFcMlW5RDrOcanc9y8sT7kU="]; got != "phone" {
t.Errorf("first peer named %q, want phone", got)
}
if got := names["wUnRD990c6aglU0TCREtmfuUTPzqSmHrd3qTORpRJBg="]; got != "the laptop" {
t.Errorf("second peer named %q, want the laptop", got)
}
for k := range names {
if k == "SECRETSECRETSECRETSECRETSECRETSECRETSECRET=" {
t.Error("the private key was read as if it were a peer")
}
}
if len(names) != 2 {
t.Errorf("%d names, want 2: %v", len(names), names)
}
}
func TestOnlineIsRecentHandshake(t *testing.T) {
for _, tc := range []struct {
name string
hs time.Time
want bool
}{
{"just now", time.Now().Add(-30 * time.Second), true},
{"a while back", time.Now().Add(-10 * time.Minute), false},
{"never", time.Time{}, false},
} {
got := !tc.hs.IsZero() && time.Since(tc.hs) < onlineWithin
if got != tc.want {
t.Errorf("%s: online = %v, want %v", tc.name, got, tc.want)
}
}
}