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>
175 lines
5.9 KiB
Go
175 lines
5.9 KiB
Go
package vpn
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"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 == nil || 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 != nil || 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A peer that has never handshaken must carry no time at all, not the zero one. omitempty
|
|
// does nothing for a time.Time, so this is the difference between the page saying "never
|
|
// connected" and saying the device was last seen in the year one.
|
|
func TestNeverConnectedHasNoTimestamp(t *testing.T) {
|
|
w := parseDump(gateDump)[0]
|
|
b, err := json.Marshal(w.Peers[1])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(string(b), "handshake") {
|
|
t.Errorf("json = %s, want no handshake field at all", b)
|
|
}
|
|
if strings.Contains(string(b), "0001-01-01") {
|
|
t.Errorf("json = %s, want no year-one timestamp", b)
|
|
}
|
|
// and the one that has connected still carries its time
|
|
if b, _ := json.Marshal(w.Peers[0]); !strings.Contains(string(b), "handshake") {
|
|
t.Errorf("json = %s, want the handshake of a peer that has one", b)
|
|
}
|
|
}
|