Files
hamlab/internal/vpn/edit_test.go
Mitchell-JohnsonandClaude Opus 5 8fb21ee95e Create, rename and remove the devices that dial in
On a tunnel this machine configures — today that means OpenWrt's UCI, which is
where the server here lives — the VPN tab can add a device, rename it and take
it away. A wg-quick host is reported as unmanaged and shows no buttons, because
half-supporting a second configuration format is worse than an honest refusal.

Adding generates the keypair and a pre-shared key on the machine, picks the
lowest free address in the tunnel's range, and hands back the complete client
profile with a QR code for the WireGuard apps. The private key in it is
generated for that one call and stored nowhere, so the profile is shown until
dismissed and then gone — the same bargain as any password this console makes.

Nothing restarts the network, which matters when the interface belongs to the
router the house is behind. `uci commit` writes the file and applies nothing;
`wg set` changes one peer on the live interface and touches nothing else.

The order of the two is chosen so they cannot disagree quietly. Adding writes
the configuration first and rolls it back if the live tunnel refuses, because a
device that works only after the next reboot is the worst of the three outcomes.
Removing does the live tunnel first, since that is the moment access actually
ends, and says plainly if the configuration half then fails.

Removing asks for the device's name to be typed back, at both ends. It is the
one action here that cannot be undone and it sits one tap from a list of devices
that all look alike.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-27 23:20:41 -07:00

127 lines
4.4 KiB
Go

package vpn
import (
"strings"
"testing"
)
func tunnel(addrs []string, peers ...string) Tunnel {
t := Tunnel{Name: "wg0", PublicKey: "SERVERKEY=", Addrs: addrs}
for _, p := range peers {
t.Peers = append(t.Peers, Peer{PublicKey: p + "KEY=", AllowedIPs: []string{p}})
}
return t
}
func TestNextAddress(t *testing.T) {
// .1 is the interface itself and .2 and .3 are taken, so the next free one is .4
got, err := NextAddress(tunnel([]string{"172.21.46.1/24"}, "172.21.46.3/32", "172.21.46.2/32"))
if err != nil || got != "172.21.46.4" {
t.Errorf("NextAddress = %q, %v; want 172.21.46.4", got, err)
}
// a gap is filled before the end is reached
got, _ = NextAddress(tunnel([]string{"172.21.46.1/24"}, "172.21.46.2/32", "172.21.46.4/32"))
if got != "172.21.46.3" {
t.Errorf("NextAddress = %q, want the gap at .3", got)
}
// an empty tunnel starts after the server
got, _ = NextAddress(tunnel([]string{"10.9.0.1/24"}))
if got != "10.9.0.2" {
t.Errorf("NextAddress = %q, want 10.9.0.2", got)
}
// a full one says so rather than handing out a duplicate
full := tunnel([]string{"10.9.0.1/30"}, "10.9.0.2/32")
if _, err := NextAddress(full); err != ErrNoRoom {
t.Errorf("err = %v, want ErrNoRoom on a full range", err)
}
// IPv6-only, or nothing at all, is refused rather than guessed
if _, err := NextAddress(tunnel([]string{"fc00::1/64"})); err == nil {
t.Error("want an error when there is no IPv4 range")
}
}
func TestSlug(t *testing.T) {
for in, want := range map[string]string{
"ip12": "ip12",
"Mitchell's iPad": "mitchells_ipad",
"my-phone": "my_phone",
" spaced out ": "spaced__out",
"!!!": "peer",
strings.Repeat("a", 40): strings.Repeat("a", 24),
} {
if got := slug(in); got != want {
t.Errorf("slug(%q) = %q, want %q", in, got, want)
}
}
}
func TestProfile(t *testing.T) {
tun := tunnel([]string{"172.21.46.1/24"})
got := profile("PRIV=", "PSK=", tun, "172.21.46.4", "home.example.com:51820", "1.1.1.1, 1.0.0.1", false)
for _, want := range []string{
"PrivateKey = PRIV=",
"Address = 172.21.46.4/24", // the interface's own prefix, not a bare /32
"DNS = 1.1.1.1, 1.0.0.1",
"PublicKey = SERVERKEY=",
"PresharedKey = PSK=",
"Endpoint = home.example.com:51820",
"AllowedIPs = 0.0.0.0/0",
} {
if !strings.Contains(got, want) {
t.Errorf("profile is missing %q:\n%s", want, got)
}
}
// no DNS line at all when none was asked for, rather than an empty one
if strings.Contains(profile("P=", "", tun, "172.21.46.4", "h:1", "", false), "DNS") {
t.Error("an empty DNS should leave the line out")
}
// split routes the private ranges instead of everything
split := profile("P=", "", tun, "172.21.46.4", "h:1", "", true)
if strings.Contains(split, "0.0.0.0/0") {
t.Errorf("a split tunnel must not route everything:\n%s", split)
}
if !strings.Contains(split, "172.21.46.0/24") {
t.Errorf("a split tunnel should include the tunnel's own range:\n%s", split)
}
}
func TestCheckEndpoint(t *testing.T) {
for _, ok := range []string{"bigham.dev:51820", "1.2.3.4:51820", "[2001:db8::1]:51820"} {
if _, err := checkEndpoint(ok); err != nil {
t.Errorf("checkEndpoint(%q) = %v, want it accepted", ok, err)
}
}
for _, bad := range []string{"", "bigham.dev", ":51820", "bigham.dev:0", "bigham.dev:notaport", "bigham.dev:99999"} {
if _, err := checkEndpoint(bad); err == nil {
t.Errorf("checkEndpoint(%q) was accepted, want refused", bad)
}
}
}
func TestHostOf(t *testing.T) {
for in, want := range map[string]string{"172.21.46.4": "172.21.46.4", "172.21.46.4/32": "172.21.46.4", " 10.0.0.9/24 ": "10.0.0.9"} {
got, err := hostOf(in)
if err != nil || got != want {
t.Errorf("hostOf(%q) = %q, %v; want %q", in, got, err, want)
}
}
if _, err := hostOf("not-an-address"); err == nil {
t.Error("hostOf accepted nonsense")
}
}
// The name is what becomes a configuration section, so it is the input worth being strict
// about; everything refused here is refused before anything is written.
func TestNameRe(t *testing.T) {
for _, ok := range []string{"ip12", "My Phone", "a", "work-laptop.2"} {
if !NameRe.MatchString(ok) {
t.Errorf("%q should be a valid name", ok)
}
}
for _, bad := range []string{"", " leading", "semi;colon", "quote'd", "back`tick", "new\nline", "$(whoami)", strings.Repeat("a", 32)} {
if NameRe.MatchString(bad) {
t.Errorf("%q should be refused as a name", bad)
}
}
}