WGX shares its name with several other WireGuard tools, so the project becomes ihasvpn, alongside ihasmail. - Module github.com/Coffey-Labs/ihasvpn, command cmd/ihasvpn, image ghcr.io/coffey-labs/ihasvpn. - Environment variables move from WGX_* to IHASVPN_*. The default database is ihasvpn.db, the nftables table is `ihasvpn`, metrics are ihasvpn_*, and the session cookie and theme key are renamed, so existing sessions end. - The mark is the ihasmail cat peeking over the edge of a shield, drawn as a vector. docs/brand/generate.py builds the mark, mono mark, wordmarks, social card, favicons and app icons from that one drawing. - The console takes ihasmail's palette: the ihasmail.org teal-navy for dark, its contrast-checked light tiers with the site's light accent, received traffic in the cat's orange and sent in teal. The wordmark weight and font stack follow ihasmail.org. - Detail values wrap at spaces before breaking inside an address, so an IPv6 tunnel address no longer splits mid-number. - The README history note about the earlier WGX installer is gone with the name it explained. Screenshots retaken.
309 lines
8.6 KiB
Go
309 lines
8.6 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/netip"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Coffey-Labs/ihasvpn/internal/config"
|
|
"github.com/Coffey-Labs/ihasvpn/internal/store"
|
|
"github.com/Coffey-Labs/ihasvpn/internal/wg"
|
|
)
|
|
|
|
func testConfig() *config.Config {
|
|
return &config.Config{
|
|
DBPath: ":memory:",
|
|
Backend: "mock",
|
|
Iface: "wg0",
|
|
ListenPort: 51820,
|
|
Subnet4: netip.MustParsePrefix("10.8.0.0/29"),
|
|
Subnet6: netip.MustParsePrefix("fd42::/64"),
|
|
HTTP: "127.0.0.1:0",
|
|
SessionIdle: time.Hour,
|
|
SessionMax: 24 * time.Hour,
|
|
TrafficRetention: 24 * time.Hour,
|
|
PollInterval: time.Hour, // tests drive polls by hand
|
|
InitialEndpoint: "vpn.example.com",
|
|
InitialDNS: "1.1.1.1",
|
|
ManageFirewall: true,
|
|
ManageSysctl: true,
|
|
}
|
|
}
|
|
|
|
func newTestEngine(t *testing.T) (*Engine, *wg.Mock) {
|
|
t.Helper()
|
|
st, err := store.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { st.Close() })
|
|
mock := wg.NewMock("wg0", false)
|
|
e := New(testConfig(), st, mock, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err := e.Start(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = e.Stop(context.Background()) })
|
|
return e, mock
|
|
}
|
|
|
|
func TestAllocate(t *testing.T) {
|
|
subnet := netip.MustParsePrefix("10.8.0.0/29") // .0 net, .1 server, .2-.6 usable, .7 broadcast
|
|
used := map[string]bool{}
|
|
var got []string
|
|
for {
|
|
a, err := allocate(subnet, used)
|
|
if err != nil {
|
|
break
|
|
}
|
|
used[a.String()] = true
|
|
got = append(got, a.String())
|
|
}
|
|
want := "10.8.0.2 10.8.0.3 10.8.0.4 10.8.0.5 10.8.0.6"
|
|
if strings.Join(got, " ") != want {
|
|
t.Fatalf("got %v, want %s", got, want)
|
|
}
|
|
a6, err := allocate(netip.MustParsePrefix("fd42::/64"), map[string]bool{"fd42::2": true})
|
|
if err != nil || a6.String() != "fd42::3" {
|
|
t.Fatalf("v6 allocation %v %v", a6, err)
|
|
}
|
|
if _, err := checkAddress(subnet, "10.8.0.1", used); err == nil {
|
|
t.Fatal("server address accepted")
|
|
}
|
|
if _, err := checkAddress(subnet, "10.9.0.1", used); err == nil {
|
|
t.Fatal("outside address accepted")
|
|
}
|
|
}
|
|
|
|
func TestCreatePeerAndConfig(t *testing.T) {
|
|
e, mock := newTestEngine(t)
|
|
ctx := context.Background()
|
|
p, err := e.CreatePeer(ctx, PeerInput{Name: "Laptop"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if p.IPv4 != "10.8.0.2" || p.IPv6 != "fd42::2" {
|
|
t.Fatalf("addresses %s %s", p.IPv4, p.IPv6)
|
|
}
|
|
if p.PrivateKey == "" || p.PresharedKey == "" {
|
|
t.Fatal("server-managed peer should have private and preshared keys")
|
|
}
|
|
cfg := e.ClientConfig(p)
|
|
for _, want := range []string{
|
|
"PrivateKey = " + p.PrivateKey,
|
|
"Address = 10.8.0.2/29, fd42::2/64",
|
|
"DNS = 1.1.1.1",
|
|
"MTU = 1420",
|
|
"PublicKey = " + e.ServerPublicKey(),
|
|
"PresharedKey = " + p.PresharedKey,
|
|
"AllowedIPs = 0.0.0.0/0, ::/0",
|
|
"Endpoint = vpn.example.com:51820",
|
|
"PersistentKeepalive = 25",
|
|
} {
|
|
if !strings.Contains(cfg, want) {
|
|
t.Errorf("config missing %q:\n%s", want, cfg)
|
|
}
|
|
}
|
|
dev, _ := mock.Device(ctx)
|
|
if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != p.PublicKey {
|
|
t.Fatal("peer not applied to the interface")
|
|
}
|
|
if len(dev.Peers[0].AllowedIPs) != 2 {
|
|
t.Fatalf("allowed ips %v", dev.Peers[0].AllowedIPs)
|
|
}
|
|
png, err := e.QRCode(p, 256)
|
|
if err != nil || len(png) < 100 {
|
|
t.Fatalf("qr: %v", err)
|
|
}
|
|
|
|
// A client-keyed peer: no private key, no QR code.
|
|
priv, _ := wg.GeneratePrivateKey()
|
|
c, err := e.CreatePeer(ctx, PeerInput{Name: "Router", PublicKey: priv.PublicKey().String(), ClientRoutes: "10.8.0.0/29"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.PrivateKey != "" || !strings.Contains(e.ClientConfig(c), "<your private key>") {
|
|
t.Fatal("client-keyed peer leaked or lacked placeholder")
|
|
}
|
|
if _, err := e.QRCode(c, 256); err == nil {
|
|
t.Fatal("QR for client-keyed peer should fail")
|
|
}
|
|
if _, err := e.CreatePeer(ctx, PeerInput{Name: "Dup", PublicKey: priv.PublicKey().String()}); err == nil {
|
|
t.Fatal("duplicate public key accepted")
|
|
}
|
|
if _, err := e.CreatePeer(ctx, PeerInput{Name: ""}); err == nil {
|
|
t.Fatal("empty name accepted")
|
|
}
|
|
}
|
|
|
|
func TestDisableResetRotateDelete(t *testing.T) {
|
|
e, mock := newTestEngine(t)
|
|
ctx := context.Background()
|
|
p, _ := e.CreatePeer(ctx, PeerInput{Name: "Phone"})
|
|
pub, _ := wg.ParseKey(p.PublicKey)
|
|
mock.Touch(pub, 1000, 2000, "203.0.113.5:1234")
|
|
e.col.poll(ctx)
|
|
l, ok := e.Live(p.ID)
|
|
if !ok || !l.Connected || l.Endpoint != "203.0.113.5:1234" {
|
|
t.Fatalf("live state %+v", l)
|
|
}
|
|
// First sight sets the memo only; the next delta counts.
|
|
mock.Touch(pub, 500, 700, "")
|
|
e.col.poll(ctx)
|
|
l, _ = e.Live(p.ID)
|
|
if l.Rx != 500 || l.Tx != 700 {
|
|
t.Fatalf("totals %d/%d, want 500/700", l.Rx, l.Tx)
|
|
}
|
|
|
|
if _, err := e.SetEnabled(ctx, p.ID, false); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dev, _ := mock.Device(ctx)
|
|
if len(dev.Peers) != 0 {
|
|
t.Fatal("disabled peer still on interface")
|
|
}
|
|
e.col.poll(ctx)
|
|
if l, _ := e.Live(p.ID); l.Connected {
|
|
t.Fatal("disabled peer reported connected")
|
|
}
|
|
if _, err := e.SetEnabled(ctx, p.ID, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dev, _ = mock.Device(ctx); len(dev.Peers) != 1 {
|
|
t.Fatal("enabled peer not back on interface")
|
|
}
|
|
// Counters restart at zero after re-add; the total must not go backwards.
|
|
mock.Touch(pub, 100, 100, "")
|
|
e.col.poll(ctx)
|
|
mock.Touch(pub, 100, 100, "")
|
|
e.col.poll(ctx)
|
|
l, _ = e.Live(p.ID)
|
|
if l.Rx != 600 || l.Tx != 800 {
|
|
t.Fatalf("totals after re-add %d/%d, want 600/800", l.Rx, l.Tx)
|
|
}
|
|
|
|
if err := e.ResetSession(ctx, p.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
r, err := e.RotateKeys(ctx, p.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r.PublicKey == p.PublicKey {
|
|
t.Fatal("key did not change")
|
|
}
|
|
dev, _ = mock.Device(ctx)
|
|
if len(dev.Peers) != 1 || dev.Peers[0].PublicKey.String() != r.PublicKey {
|
|
t.Fatal("rotated key not applied")
|
|
}
|
|
if err := e.col.flush(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := e.DeletePeer(ctx, p.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := e.Peer(p.ID); err != ErrNotFound {
|
|
t.Fatal("deleted peer still present")
|
|
}
|
|
if dev, _ = mock.Device(ctx); len(dev.Peers) != 0 {
|
|
t.Fatal("deleted peer still on interface")
|
|
}
|
|
}
|
|
|
|
func TestExpiryAndReconcile(t *testing.T) {
|
|
e, mock := newTestEngine(t)
|
|
ctx := context.Background()
|
|
past := time.Now().Add(-time.Minute)
|
|
p, err := e.CreatePeer(ctx, PeerInput{Name: "Temp", ExpiresAt: &past})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dev, _ := mock.Device(ctx); len(dev.Peers) != 0 {
|
|
t.Fatal("expired peer applied")
|
|
}
|
|
future := time.Now().Add(time.Hour)
|
|
if _, err := e.UpdatePeer(ctx, p.ID, PeerInput{Name: "Temp", ExpiresAt: &future}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
|
|
t.Fatal("un-expired peer not applied")
|
|
}
|
|
// Someone removes the peer by hand; reconcile puts it back.
|
|
pub, _ := wg.ParseKey(p.PublicKey)
|
|
_ = mock.RemovePeer(ctx, pub)
|
|
if err := e.reconcile(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
|
|
t.Fatal("reconcile did not restore peer")
|
|
}
|
|
// A stranger appears on the interface; reconcile removes it.
|
|
stray, _ := wg.GeneratePrivateKey()
|
|
_ = mock.SetPeer(ctx, wg.PeerConfig{PublicKey: stray.PublicKey()})
|
|
if err := e.reconcile(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dev, _ := mock.Device(ctx); len(dev.Peers) != 1 {
|
|
t.Fatal("reconcile did not remove stray peer")
|
|
}
|
|
}
|
|
|
|
func TestSettingsValidation(t *testing.T) {
|
|
e, _ := newTestEngine(t)
|
|
s := e.Settings()
|
|
s.EndpointHost = ""
|
|
if err := e.UpdateSettings(context.Background(), s); err == nil {
|
|
t.Fatal("empty endpoint accepted")
|
|
}
|
|
s = e.Settings()
|
|
s.MTU = 100
|
|
if err := e.UpdateSettings(context.Background(), s); err == nil {
|
|
t.Fatal("tiny MTU accepted")
|
|
}
|
|
s = e.Settings()
|
|
s.DNS = "1.1.1.1, example.com"
|
|
s.MTU = 1380
|
|
if err := e.UpdateSettings(context.Background(), s); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if e.Settings().MTU != 1380 {
|
|
t.Fatal("settings not persisted")
|
|
}
|
|
}
|
|
|
|
func TestPersistenceAcrossRestart(t *testing.T) {
|
|
st, err := store.Open(":memory:")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
ctx := context.Background()
|
|
e1 := New(testConfig(), st, wg.NewMock("wg0", false), log)
|
|
if err := e1.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, _ := e1.CreatePeer(ctx, PeerInput{Name: "Keep"})
|
|
key := e1.ServerPublicKey()
|
|
_ = e1.Stop(ctx)
|
|
|
|
e2 := New(testConfig(), st, wg.NewMock("wg0", false), log)
|
|
if err := e2.Start(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer e2.Stop(ctx)
|
|
if e2.ServerPublicKey() != key {
|
|
t.Fatal("server key changed across restart")
|
|
}
|
|
got, err := e2.Peer(p.ID)
|
|
if err != nil || got.PublicKey != p.PublicKey {
|
|
t.Fatal("peer lost across restart")
|
|
}
|
|
if dev, _ := e2.be.Device(ctx); len(dev.Peers) != 1 {
|
|
t.Fatal("peer not re-applied after restart")
|
|
}
|
|
}
|