mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-09 19:57:14 +00:00
fef9a4b20a
TPROXY never rewrites a packet's own destination address, only the routing decision. A default-deny firewall whose INPUT chain sanity-checks "is this destination actually local" (UFW's ufw-not-local, via addrtype --dst-type LOCAL, is a concrete example) silently drops the redirected packet before Xray's socket ever sees it -- RouteThroughXray looked fully configured (TPROXY rule present and counting, Xray listening with IP_TRANSPARENT set) yet every peer's traffic vanished with no trace on either side. Adds an idempotent, never-torn-down "iptables -I INPUT 1 -m mark --mark <fwmark> -j ACCEPT" alongside the existing shared policy route, so this works regardless of which firewall manager owns the rest of the INPUT chain.
527 lines
20 KiB
Go
527 lines
20 KiB
Go
package amneziawg
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func mkInboundSettings(t *testing.T, server *ServerSettings, clients []model.Client) string {
|
|
t.Helper()
|
|
bs, err := json.Marshal(InboundSettings{Server: server, Clients: clients})
|
|
if err != nil {
|
|
t.Fatalf("marshal settings: %v", err)
|
|
}
|
|
return string(bs)
|
|
}
|
|
|
|
func validServer() *ServerSettings {
|
|
return &ServerSettings{
|
|
PrivateKey: "serverPriv",
|
|
PublicKey: "serverPub",
|
|
SubnetIP: "10.8.1.0",
|
|
SubnetCIDR: 24,
|
|
}
|
|
}
|
|
|
|
func TestInstanceFromInboundParsesEnabledPeers(t *testing.T) {
|
|
settings := mkInboundSettings(t, validServer(), []model.Client{
|
|
{Email: "a@x", Enable: true, PublicKey: "pubA", PreSharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
{Email: "b@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
|
|
{Email: "c@x", Enable: true, PublicKey: "", AllowedIPs: []string{"10.8.1.4/32"}}, // no key: skipped
|
|
{Email: "d@x", Enable: true, PublicKey: "pubD", AllowedIPs: nil}, // no address: skipped
|
|
})
|
|
ib := &model.Inbound{Id: 7, Tag: "awg-tag", Protocol: model.AmneziaWG, Port: 51820, Settings: settings}
|
|
|
|
inst, ok := InstanceFromInbound(ib)
|
|
if !ok {
|
|
t.Fatal("expected a usable instance")
|
|
}
|
|
if inst.Id != 7 || inst.Tag != "awg-tag" || inst.ListenPort != 51820 {
|
|
t.Fatalf("instance identity not carried over: %+v", inst)
|
|
}
|
|
if inst.InterfaceName != "awg7" {
|
|
t.Fatalf("InterfaceName = %q, want awg7", inst.InterfaceName)
|
|
}
|
|
if len(inst.Address) != 1 || inst.Address[0] != "10.8.1.1/24" {
|
|
t.Fatalf("Address = %v, want [10.8.1.1/24]", inst.Address)
|
|
}
|
|
if len(inst.Peers) != 1 {
|
|
t.Fatalf("Peers = %+v, want exactly 1 (only a@x qualifies)", inst.Peers)
|
|
}
|
|
p := inst.Peers[0]
|
|
if p.Email != "a@x" || p.PublicKey != "pubA" || p.PresharedKey != "pskA" || len(p.AllowedIPs) != 1 || p.AllowedIPs[0] != "10.8.1.2/32" {
|
|
t.Fatalf("peer mismatch: %+v", p)
|
|
}
|
|
}
|
|
|
|
func TestInstanceFromInboundRejectsWrongProtocol(t *testing.T) {
|
|
settings := mkInboundSettings(t, validServer(), []model.Client{
|
|
{Email: "a@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
})
|
|
ib := &model.Inbound{Id: 1, Protocol: model.VLESS, Settings: settings}
|
|
if _, ok := InstanceFromInbound(ib); ok {
|
|
t.Fatal("non-AmneziaWG inbound must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestInstanceFromInboundRejectsNil(t *testing.T) {
|
|
if _, ok := InstanceFromInbound(nil); ok {
|
|
t.Fatal("nil inbound must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestInstanceFromInboundRejectsMissingServer(t *testing.T) {
|
|
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `{"clients":[]}`}
|
|
if _, ok := InstanceFromInbound(ib); ok {
|
|
t.Fatal("settings with no server block must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestInstanceFromInboundRejectsUnparseableSettings(t *testing.T) {
|
|
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: `not json`}
|
|
if _, ok := InstanceFromInbound(ib); ok {
|
|
t.Fatal("unparseable settings must be rejected")
|
|
}
|
|
}
|
|
|
|
func TestInstanceFromInboundEmptyWhenNoEnabledPeers(t *testing.T) {
|
|
settings := mkInboundSettings(t, validServer(), []model.Client{
|
|
{Email: "a@x", Enable: false, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
})
|
|
ib := &model.Inbound{Id: 1, Protocol: model.AmneziaWG, Settings: settings}
|
|
if _, ok := InstanceFromInbound(ib); ok {
|
|
t.Fatal("an inbound with zero enabled peers must be skipped, like mtproto.InstanceFromInbound")
|
|
}
|
|
}
|
|
|
|
func TestServerAddress(t *testing.T) {
|
|
cases := []struct {
|
|
subnet string
|
|
cidr int
|
|
want string
|
|
}{
|
|
{"10.8.1.0", 24, "10.8.1.1/24"},
|
|
{"10.8.1.0", 0, "10.8.1.1/24"}, // cidr <= 0 defaults to /24
|
|
{"10.8.1.5", 24, "10.8.1.1/24"}, // non-network base: must not collide with peer allocation starting at .2
|
|
{"10.8.1.254", 24, "10.8.1.1/24"},
|
|
{"192.168.5.10", 32, "192.168.5.10/32"}, // /32 has no host bits: used as-is
|
|
}
|
|
for _, c := range cases {
|
|
if got := serverAddress(c.subnet, c.cidr); got != c.want {
|
|
t.Errorf("serverAddress(%q, %d) = %q, want %q", c.subnet, c.cidr, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fixedObfuscation is a deterministic Obfuscation20 for tests that compare
|
|
// two instances for equality — GenerateObfuscation20 is randomized per call
|
|
// by design (see its doc comment) and must never be used where the test
|
|
// expects two "identical" instances to actually match.
|
|
func fixedObfuscation() Obfuscation20 {
|
|
return Obfuscation20{Jc: 4, Jmin: 40, Jmax: 100, S1: 30, S2: 90, S3: 20, S4: 10, H1: "10-2000", H2: "3000-5000", H3: "6000-8000", H4: "9000-11000", I1: "<r 64>"}
|
|
}
|
|
|
|
func baseInstance() Instance {
|
|
return Instance{
|
|
Id: 1,
|
|
Tag: "awg-1",
|
|
InterfaceName: "awg1",
|
|
ListenPort: 51820,
|
|
PrivateKey: "priv",
|
|
PublicKey: "pub",
|
|
Address: []string{"10.8.1.1/24"},
|
|
Obfuscation: fixedObfuscation(),
|
|
Peers: []Peer{
|
|
{Email: "a@x", PublicKey: "pubA", PresharedKey: "pskA", AllowedIPs: []string{"10.8.1.2/32"}},
|
|
{Email: "b@x", PublicKey: "pubB", AllowedIPs: []string{"10.8.1.3/32"}},
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestStructuralFingerprintStableAndSensitive(t *testing.T) {
|
|
a := baseInstance()
|
|
b := baseInstance()
|
|
if a.structuralFingerprint() != b.structuralFingerprint() {
|
|
t.Fatal("identical instances must produce the same structural fingerprint")
|
|
}
|
|
b.ListenPort = 51821
|
|
if a.structuralFingerprint() == b.structuralFingerprint() {
|
|
t.Fatal("a listen port change must change the structural fingerprint")
|
|
}
|
|
c := baseInstance()
|
|
c.Peers[0].AllowedIPs = []string{"10.8.1.99/32"}
|
|
if a.structuralFingerprint() != c.structuralFingerprint() {
|
|
t.Fatal("a peer-only change must NOT change the structural fingerprint")
|
|
}
|
|
|
|
d := baseInstance()
|
|
d.IPv6Enabled = true
|
|
if a.structuralFingerprint() == d.structuralFingerprint() {
|
|
t.Fatal("enabling IPv6 must change the structural fingerprint")
|
|
}
|
|
|
|
e := baseInstance()
|
|
e.IPv6Enabled = true
|
|
f := baseInstance()
|
|
f.IPv6Enabled = true
|
|
f.IPv6ExternalInterface = "eth1"
|
|
if e.structuralFingerprint() == f.structuralFingerprint() {
|
|
t.Fatal("changing IPv6ExternalInterface must change the structural fingerprint -- otherwise the edit is a complete no-op")
|
|
}
|
|
|
|
g := baseInstance()
|
|
g.RouteThroughXray = true
|
|
if a.structuralFingerprint() == g.structuralFingerprint() {
|
|
t.Fatal("toggling RouteThroughXray must change the structural fingerprint -- it changes whether PostUp/PostDown contain any TPROXY rules at all")
|
|
}
|
|
}
|
|
|
|
func TestPeersFingerprintOrderIndependentButContentSensitive(t *testing.T) {
|
|
a := baseInstance()
|
|
reordered := baseInstance()
|
|
reordered.Peers[0], reordered.Peers[1] = reordered.Peers[1], reordered.Peers[0]
|
|
if a.peersFingerprint() != reordered.peersFingerprint() {
|
|
t.Fatal("reordering peers must not change the peers fingerprint")
|
|
}
|
|
|
|
changed := baseInstance()
|
|
changed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
|
if a.peersFingerprint() == changed.peersFingerprint() {
|
|
t.Fatal("changing a peer's AllowedIPs must change the peers fingerprint")
|
|
}
|
|
|
|
fewer := baseInstance()
|
|
fewer.Peers = fewer.Peers[:1]
|
|
if a.peersFingerprint() == fewer.peersFingerprint() {
|
|
t.Fatal("removing a peer must change the peers fingerprint")
|
|
}
|
|
}
|
|
|
|
func TestEnsureActionFor(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
up bool
|
|
curStruct, curHostRules, curPeers string
|
|
newStruct, newHostRules, newPeers string
|
|
want ensureAction
|
|
}{
|
|
{"down forces restart even if identical", false, "s", "f", "p", "s", "f", "p", ensureRestart},
|
|
{"structural change forces restart", true, "s1", "f", "p", "s2", "f", "p", ensureRestart},
|
|
{"port-forward change forces restart", true, "s", "f1", "p", "s", "f2", "p", ensureRestart},
|
|
{"peer-ip change (its TPROXY rule) forces restart", true, "s", "ip:old", "p", "s", "ip:new", "p", ensureRestart},
|
|
{"peers-only change reloads", true, "s", "f", "p1", "s", "f", "p2", ensureReload},
|
|
{"identical up interface is a noop", true, "s", "f", "p", "s", "f", "p", ensureNoop},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
got := ensureActionFor(c.up, c.curStruct, c.curHostRules, c.curPeers, c.newStruct, c.newHostRules, c.newPeers)
|
|
if got != c.want {
|
|
t.Errorf("ensureActionFor() = %v, want %v", got, c.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNextTrafficBaseline(t *testing.T) {
|
|
prev := map[string]peerCounters{"pubA": {rx: 100, tx: 200}}
|
|
|
|
if got := nextTrafficBaseline(ensureReload, prev); len(got) != 1 || got["pubA"] != prev["pubA"] {
|
|
t.Errorf("a reload must preserve the previous baseline (syncconf never resets kernel counters), got %v", got)
|
|
}
|
|
if got := nextTrafficBaseline(ensureRestart, prev); len(got) != 0 {
|
|
t.Errorf("a restart must reset the baseline to empty (awg-quick down+up zeroes kernel counters), got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestHostRulesFingerprintCoversForwardedPortsAndPeerIP(t *testing.T) {
|
|
a := baseInstance()
|
|
b := baseInstance()
|
|
if a.hostRulesFingerprint() != b.hostRulesFingerprint() {
|
|
t.Fatal("identical instances must produce the same host-rules fingerprint")
|
|
}
|
|
|
|
forwarded := baseInstance()
|
|
forwarded.Peers[0].ForwardedPorts = "80,443"
|
|
if a.hostRulesFingerprint() == forwarded.hostRulesFingerprint() {
|
|
t.Fatal("adding ForwardedPorts must change the host-rules fingerprint")
|
|
}
|
|
|
|
fewer := baseInstance()
|
|
fewer.Peers = fewer.Peers[:1]
|
|
if a.hostRulesFingerprint() == fewer.hostRulesFingerprint() {
|
|
t.Fatal("removing a peer must change the host-rules fingerprint -- one fewer peer entry exists regardless of what's tracked per peer")
|
|
}
|
|
|
|
// RouteThroughXray off (baseInstance's default): no TPROXY rule depends
|
|
// on a peer's IPv4 address, so re-IPing one must NOT force a bounce --
|
|
// this is the whole point of making the bridge opt-in: an instance that
|
|
// never uses it keeps the syncconf fast path for a plain re-IP.
|
|
reIPedNoRoute := baseInstance()
|
|
reIPedNoRoute.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
|
if a.hostRulesFingerprint() != reIPedNoRoute.hostRulesFingerprint() {
|
|
t.Fatal("with RouteThroughXray off, changing a peer's IP must NOT change the host-rules fingerprint")
|
|
}
|
|
|
|
// RouteThroughXray on: now the TPROXY rule really is keyed on the IP.
|
|
routed := baseInstance()
|
|
routed.RouteThroughXray = true
|
|
routedReIPed := baseInstance()
|
|
routedReIPed.RouteThroughXray = true
|
|
routedReIPed.Peers[0].AllowedIPs = []string{"10.8.1.250/32"}
|
|
if routed.hostRulesFingerprint() == routedReIPed.hostRulesFingerprint() {
|
|
t.Fatal("with RouteThroughXray on, changing a peer's IP must change the host-rules fingerprint -- its TPROXY rule is keyed on that IP")
|
|
}
|
|
|
|
// IPv6Enabled off (baseInstance's default): no NDP-proxy entry depends
|
|
// on a peer's IPv6 address either, so adding one must not force a bounce.
|
|
ip6AddedNoIPv6 := baseInstance()
|
|
ip6AddedNoIPv6.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
|
|
if a.hostRulesFingerprint() != ip6AddedNoIPv6.hostRulesFingerprint() {
|
|
t.Fatal("with IPv6Enabled off, adding a peer's IPv6 address must NOT change the host-rules fingerprint")
|
|
}
|
|
|
|
ip6Base := baseInstance()
|
|
ip6Base.IPv6Enabled = true
|
|
ip6Added := baseInstance()
|
|
ip6Added.IPv6Enabled = true
|
|
ip6Added.Peers[0].AllowedIPs = []string{"10.8.1.2/32", "fd86:ea04:1115::2/128"}
|
|
if ip6Base.hostRulesFingerprint() == ip6Added.hostRulesFingerprint() {
|
|
t.Fatal("with IPv6Enabled on, adding a peer's IPv6 address must change the host-rules fingerprint -- its NDP-proxy entry is keyed on it, and a change here must force the full bounce that (re-)runs PostUp")
|
|
}
|
|
}
|
|
|
|
func TestRouteEgressComment(t *testing.T) {
|
|
if got := routeEgressComment(""); got != "awg-route" {
|
|
t.Errorf("empty email must fall back to awg-route, got %q", got)
|
|
}
|
|
a := routeEgressComment("a@x")
|
|
b := routeEgressComment("b@x")
|
|
if a == b {
|
|
t.Fatal("different emails must produce different comment tags")
|
|
}
|
|
if a != routeEgressComment("a@x") {
|
|
t.Fatal("the same email must always produce the same comment tag")
|
|
}
|
|
}
|
|
|
|
func TestRouteEgressLines(t *testing.T) {
|
|
up := routeEgressLines("-A", "awg1", "10.8.1.2/32", "a@x", 63101)
|
|
if len(up) != 2 {
|
|
t.Fatalf("expected one TPROXY line per protocol (tcp+udp), got %d: %v", len(up), up)
|
|
}
|
|
for _, proto := range []string{"tcp", "udp"} {
|
|
found := false
|
|
for _, l := range up {
|
|
if !strings.Contains(l, "-p "+proto) {
|
|
continue
|
|
}
|
|
found = true
|
|
if !strings.Contains(l, "-i awg1") || !strings.Contains(l, "-s 10.8.1.2") ||
|
|
!strings.Contains(l, "--on-port 63101") ||
|
|
!strings.Contains(l, "--on-ip 127.0.0.1") ||
|
|
!strings.Contains(l, fmt.Sprintf("--tproxy-mark %#x/%#x", EgressFwmark, EgressFwmark)) ||
|
|
!strings.Contains(l, "-A PREROUTING") {
|
|
t.Errorf("%s line missing expected fields: %s", proto, l)
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("missing a %s TPROXY line in %v", proto, up)
|
|
}
|
|
}
|
|
if strings.Contains(up[0], "10.8.1.2/32") {
|
|
t.Errorf("expected the /32 mask stripped from the source match, got %s", up[0])
|
|
}
|
|
|
|
down := routeEgressLines("-D", "awg1", "10.8.1.2/32", "a@x", 63101)
|
|
if len(down) != 2 || !strings.Contains(down[0], "-D PREROUTING") {
|
|
t.Fatalf("expected symmetric -D lines, got %v", down)
|
|
}
|
|
|
|
if got := routeEgressLines("-A", "awg1", "", "a@x", 63101); got != nil {
|
|
t.Errorf("empty clientIP must yield no lines, got %v", got)
|
|
}
|
|
}
|
|
|
|
func TestEgressPortForInbound(t *testing.T) {
|
|
if got := EgressPortForInbound(1); got != EgressBasePort+1 {
|
|
t.Errorf("EgressPortForInbound(1) = %d, want %d", got, EgressBasePort+1)
|
|
}
|
|
if EgressPortForInbound(1) == EgressPortForInbound(2) {
|
|
t.Fatal("different inbound ids must derive different ports")
|
|
}
|
|
}
|
|
|
|
func TestDefaultPostUpDownOmitsTproxyWhenRouteThroughXrayOff(t *testing.T) {
|
|
inst := baseInstance() // RouteThroughXray defaults to false
|
|
up, down := defaultPostUpDown(inst, "eth0")
|
|
|
|
if strings.Contains(up, "TPROXY") || strings.Contains(up, "ip rule add fwmark") {
|
|
t.Errorf("RouteThroughXray off must emit no TPROXY/policy-route lines in PostUp, got:\n%s", up)
|
|
}
|
|
if strings.Contains(down, "TPROXY") {
|
|
t.Errorf("RouteThroughXray off must emit no TPROXY lines in PostDown, got:\n%s", down)
|
|
}
|
|
}
|
|
|
|
func TestDefaultPostUpDownEmitsTproxyForEveryPeerWhenRouteThroughXrayOn(t *testing.T) {
|
|
inst := baseInstance() // two peers, a@x and b@x
|
|
inst.RouteThroughXray = true
|
|
up, down := defaultPostUpDown(inst, "eth0")
|
|
|
|
wantPort := fmt.Sprintf("--on-port %d", EgressPortForInbound(inst.Id))
|
|
if !strings.Contains(up, "TPROXY") || !strings.Contains(up, wantPort) {
|
|
t.Errorf("expected TPROXY rules targeting this instance's own bridge port in PostUp, got:\n%s", up)
|
|
}
|
|
if !strings.Contains(down, "TPROXY") {
|
|
t.Errorf("expected matching TPROXY removals in PostDown, got:\n%s", down)
|
|
}
|
|
if !strings.Contains(up, fmt.Sprintf("ip rule add fwmark %#x", EgressFwmark)) {
|
|
t.Errorf("expected the shared policy route to be added once in PostUp, got:\n%s", up)
|
|
}
|
|
if wantCheck := fmt.Sprintf("ip rule list | grep -q 'fwmark %#x lookup %d'", EgressFwmark, EgressTable); !strings.Contains(up, wantCheck) {
|
|
t.Errorf("expected an existence check before 'ip rule add', so repeated bounces don't accumulate duplicate rules, got:\n%s", up)
|
|
}
|
|
if strings.Contains(down, "ip rule") || strings.Contains(down, "ip route") {
|
|
t.Error("the shared policy route must never be removed in PostDown -- other instances may still need it")
|
|
}
|
|
// Both peers get TPROXY'd once opted in: 2 peers * 2 protocols.
|
|
if got := strings.Count(up, "TPROXY"); got != 4 {
|
|
t.Errorf("expected exactly 4 TPROXY lines (tcp+udp for each of the 2 peers), got %d in:\n%s", got, up)
|
|
}
|
|
|
|
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
|
|
upNone, _ := defaultPostUpDown(none, "eth0")
|
|
if strings.Contains(upNone, "TPROXY") || strings.Contains(upNone, "ip rule add fwmark") {
|
|
t.Errorf("an instance with no peers must not emit any TPROXY/policy-route lines, got:\n%s", upNone)
|
|
}
|
|
}
|
|
|
|
// TPROXY never rewrites a packet's own destination address -- only the
|
|
// routing decision changes -- so a default-deny INPUT chain that sanity-checks
|
|
// "is this destination actually local" (e.g. UFW's ufw-not-local, via
|
|
// addrtype --dst-type LOCAL) drops it before Xray's socket ever sees it, even
|
|
// though TPROXY's own mangle-table counters keep incrementing the whole time.
|
|
// This was a real, hard-to-diagnose production outage: RouteThroughXray
|
|
// looked fully configured (TPROXY rule present, Xray socket listening with
|
|
// IP_TRANSPARENT set) yet every peer's traffic silently vanished.
|
|
func TestDefaultPostUpDownAddsInputAcceptForFwmarkWhenRouteThroughXrayOn(t *testing.T) {
|
|
inst := baseInstance() // two peers, a@x and b@x
|
|
inst.RouteThroughXray = true
|
|
up, down := defaultPostUpDown(inst, "eth0")
|
|
|
|
wantCheck := fmt.Sprintf("iptables -C INPUT -m mark --mark %#x -j ACCEPT", EgressFwmark)
|
|
wantInsert := fmt.Sprintf("iptables -I INPUT 1 -m mark --mark %#x -j ACCEPT", EgressFwmark)
|
|
if !strings.Contains(up, wantCheck) || !strings.Contains(up, wantInsert) {
|
|
t.Errorf("expected an idempotent INPUT accept for the shared fwmark in PostUp, got:\n%s", up)
|
|
}
|
|
if strings.Contains(down, "-m mark --mark") {
|
|
t.Error("the shared INPUT accept must never be removed in PostDown -- other instances may still need it, same as the policy route")
|
|
}
|
|
|
|
none := Instance{Id: 2, InterfaceName: "awg2", RouteThroughXray: true} // no peers at all
|
|
upNone, _ := defaultPostUpDown(none, "eth0")
|
|
if strings.Contains(upNone, "-m mark --mark") {
|
|
t.Errorf("an instance with no peers must not emit the INPUT accept either, got:\n%s", upNone)
|
|
}
|
|
}
|
|
|
|
func TestGenerateServerConfigContainsExpectedLines(t *testing.T) {
|
|
inst := baseInstance()
|
|
inst.ExternalInterface = "eth0"
|
|
cfg := generateServerConfig(inst)
|
|
|
|
want := []string{
|
|
"[Interface]",
|
|
"PrivateKey = priv",
|
|
"Address = 10.8.1.1/24",
|
|
"ListenPort = 51820",
|
|
"[Peer]",
|
|
"PublicKey = pubA",
|
|
"PresharedKey = pskA",
|
|
"AllowedIPs = 10.8.1.2/32",
|
|
"PublicKey = pubB",
|
|
"AllowedIPs = 10.8.1.3/32",
|
|
"MASQUERADE",
|
|
}
|
|
for _, w := range want {
|
|
if !strings.Contains(cfg, w) {
|
|
t.Errorf("generated config missing %q\n---\n%s", w, cfg)
|
|
}
|
|
}
|
|
// The second peer has no PresharedKey — its block must not emit the field at all.
|
|
if strings.Count(cfg, "PresharedKey") != 1 {
|
|
t.Errorf("expected exactly one PresharedKey line (peer b@x has none), got config:\n%s", cfg)
|
|
}
|
|
}
|
|
|
|
func TestWriteObfuscationDefaultsBlankH(t *testing.T) {
|
|
var b strings.Builder
|
|
writeObfuscation(&b, Obfuscation20{})
|
|
out := b.String()
|
|
for i, want := range []string{"H1 = 1", "H2 = 2", "H3 = 3", "H4 = 4"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("blank H%d must fall back to default %q, got:\n%s", i+1, want, out)
|
|
}
|
|
}
|
|
// S3/S4/I1 are zero-valued here and must be omitted entirely.
|
|
if strings.Contains(out, "S3") || strings.Contains(out, "S4") || strings.Contains(out, "I1") {
|
|
t.Errorf("zero-valued S3/S4/I1 must be omitted, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestInterfaceNameForID(t *testing.T) {
|
|
if got := interfaceNameForID(42); got != "awg42" {
|
|
t.Errorf("interfaceNameForID(42) = %q, want awg42", got)
|
|
}
|
|
}
|
|
|
|
func TestInboundIDForInterfaceName(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
wantID int
|
|
wantOK bool
|
|
}{
|
|
{"awg42", 42, true},
|
|
{"awg0", 0, true},
|
|
{"awg", 0, false}, // no digits after the prefix
|
|
{"wg0", 0, false}, // wrong prefix entirely (plain WireGuard)
|
|
{"awgabc", 0, false}, // non-numeric suffix
|
|
{"awg-1", 0, false}, // Atoi rejects the leading '-' as part of TrimPrefix's leftover, but guard anyway
|
|
}
|
|
for _, c := range cases {
|
|
id, ok := inboundIDForInterfaceName(c.name)
|
|
if ok != c.wantOK || (ok && id != c.wantID) {
|
|
t.Errorf("inboundIDForInterfaceName(%q) = (%d, %v), want (%d, %v)", c.name, id, ok, c.wantID, c.wantOK)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestOrphanedInterfaces(t *testing.T) {
|
|
confFiles := []string{
|
|
"awg1.conf", // in want -> not orphaned
|
|
"awg2.conf", // not in want -> orphaned
|
|
"awg3.conf", // not in want -> orphaned
|
|
"notes.txt", // wrong suffix -> ignored
|
|
"awgxyz.conf", // unparseable id -> ignored
|
|
}
|
|
want := map[int]struct{}{1: {}}
|
|
|
|
got := orphanedInterfaces(confFiles, want)
|
|
slices.Sort(got)
|
|
if wantOut := []string{"awg2", "awg3"}; !slices.Equal(got, wantOut) {
|
|
t.Errorf("orphanedInterfaces() = %v, want %v", got, wantOut)
|
|
}
|
|
}
|
|
|
|
func TestOrphanedInterfacesEmptyWantOrphansEverything(t *testing.T) {
|
|
got := orphanedInterfaces([]string{"awg5.conf"}, map[int]struct{}{})
|
|
if want := []string{"awg5"}; !slices.Equal(got, want) {
|
|
t.Errorf("orphanedInterfaces() = %v, want %v", got, want)
|
|
}
|
|
}
|