Files
Aleksey fee7b1621d fix(amneziawg): validate peer subnets before enabling kernel mode (#6965)
A kernel-mode AmneziaWG inbound routes and masquerades only its interface subnet, so peers whose AllowedIPs lay outside it (a restored backup, a changed subnet) handshook but got no internet. Enabling kernel mode, saving an active kernel inbound, and adding, enabling or re-addressing a client now refuse out-of-subnet peers with the offending client named. Legacy stray peers still start and can still be edited or disabled.
2026-11-03 15:52:26 +01:00

256 lines
11 KiB
Go

package amneziawgkern
import (
"fmt"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
)
func testInstance() amneziawg.Instance {
return amneziawg.Instance{
Id: 1,
ListenPort: 31884,
PrivateKey: "cPrivateKeyBase64ValueForTestingOnlyPadding0=",
Address: []string{"10.8.1.1/24"},
MTU: 1380,
Obfuscation: amneziawg.Obfuscation31{
Jc: 3, Jmin: 62, Jmax: 284,
S1: 15, S2: 89, S3: 40, S4: 12,
HeaderProtectionKey: "hpkBase64ValueForTestingOnlyPaddingXXXXXXXX=",
ContentPaddingAddition: "14-47",
RekeyAfterTime: "107-133",
RandomTrailers: true,
DisableCookies: true,
},
Peers: []amneziawg.Peer{
{Email: "alice", PublicKey: "peerAPublicKeyBase64ValueForTestsPadding000=", AllowedIPs: []string{"10.8.1.2/32"}},
{Email: "bob", PublicKey: "peerBPublicKeyBase64ValueForTestsPadding000=", AllowedIPs: []string{"10.8.1.3/32"}, PresharedKey: "pskBase64ValueForTestingOnlyPaddingXXXXXXX="},
},
}
}
// TestRenderEmitsOnlySetconfKeys: `awg setconf` rejects wg-quick keys (Address,
// MTU, hooks) outright, and a hook line would reintroduce a root-shell sink.
func TestRenderEmitsOnlySetconfKeys(t *testing.T) {
conf, err := Render(testInstance())
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{
"PrivateKey = cPrivateKeyBase64ValueForTestingOnlyPadding0=\n",
"ListenPort = 31884\n",
"Jc = 3\n", "S3 = 40\n", "S4 = 12\n",
"HeaderProtectionKey = hpkBase64ValueForTestingOnlyPaddingXXXXXXXX=\n",
"ContentPaddingAddition = 14-47\n",
"RandomTrailers = on\n", "DisableCookies = on\n",
"# alice\nPublicKey = peerAPublicKeyBase64ValueForTestsPadding000=\nAllowedIPs = 10.8.1.2/32\n",
"PresharedKey = pskBase64ValueForTestingOnlyPaddingXXXXXXX=\n",
} {
if !strings.Contains(conf, want) {
t.Errorf("rendered config lacks %q:\n%s", want, conf)
}
}
for _, line := range strings.Split(conf, "\n") {
key, _, _ := strings.Cut(line, " = ")
switch key {
case "Address", "MTU", "DNS", "Table", "PreUp", "PostUp", "PreDown", "PostDown", "SaveConfig":
t.Errorf("rendered config carries wg-quick key %q", line)
}
}
if !validOwnedConfig(conf, ownedInterfaceName(1)) {
t.Errorf("ownership grammar rejects the panel's own rendered config:\n%s", conf)
}
}
// TestRenderFillsHeaderDefaults: the 3.x module and tools reject a missing H1-H4,
// so a blank one must fall back to the vanilla message type it replaces.
func TestRenderFillsHeaderDefaults(t *testing.T) {
inst := testInstance()
inst.Obfuscation.H2 = "1000-2000"
conf, err := Render(inst)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"H1 = 1\n", "H2 = 1000-2000\n", "H3 = 3\n", "H4 = 4\n"} {
if !strings.Contains(conf, want) {
t.Errorf("rendered config lacks %q", want)
}
}
}
// TestRenderRejectsControlCharacters: a newline in a client value would add a
// directive of the caller's choosing to the file the kernel is configured from.
func TestRenderRejectsControlCharacters(t *testing.T) {
cases := map[string]func(*amneziawg.Instance){
"peer key": func(i *amneziawg.Instance) { i.Peers[0].PublicKey = "x\n[Interface]\nListenPort = 1" },
"psk": func(i *amneziawg.Instance) { i.Peers[1].PresharedKey = "x\rPublicKey = y" },
"email": func(i *amneziawg.Instance) { i.Peers[0].Email = "a\nAllowedIPs = 0.0.0.0/0" },
"allowed ips": func(i *amneziawg.Instance) { i.Peers[0].AllowedIPs = []string{"10.8.1.2/32\nEndpoint = 1.2.3.4:1"} },
"i1": func(i *amneziawg.Instance) { i.Obfuscation.I1 = "<b 0x01>\nJc = 1" },
}
for name, mutate := range cases {
t.Run(name, func(t *testing.T) {
inst := testInstance()
mutate(&inst)
if _, err := Render(inst); err == nil || !strings.Contains(err.Error(), "control characters") {
t.Fatalf("Render accepted a control character, err = %v", err)
}
})
}
}
// TestBuildSpecMasqueradesSubnetNotHostAddress: matching the server's own
// 10.8.1.1 would NAT nothing the clients send.
func TestBuildSpecMasqueradesSubnetNotHostAddress(t *testing.T) {
s, err := buildSpec(Desired{Instance: testInstance(), EgressInterface: "ens3"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(s.nat, "ip saddr 10.8.1.0/24 oifname \"ens3\" masquerade") {
t.Fatalf("NAT transaction does not masquerade the tunnel subnet:\n%s", s.nat)
}
if !strings.HasPrefix(s.nat, "add table ip xui_awg1\ndelete table ip xui_awg1\n") {
t.Fatalf("NAT transaction does not replace its own table atomically:\n%s", s.nat)
}
if s.forward != (forwardRules{tunnel: "xui-awg1", egress: "ens3"}) {
t.Fatalf("forward rules = %+v, want tunnel xui-awg1 -> ens3", s.forward)
}
}
func TestValidatePeerSubnetsRejectsPeersOutsideNATSubnet(t *testing.T) {
for _, allowedIP := range []string{"10.9.1.2/32", "10.8.0.0/16"} {
t.Run(allowedIP, func(t *testing.T) {
inst := testInstance()
inst.Peers[0].AllowedIPs = []string{allowedIP}
err := ValidatePeerSubnets(inst)
want := fmt.Sprintf("amneziawgkern: peer %q allowedIPs %q is outside kernel interface subnets [10.8.1.0/24]; align the server subnet and client addresses before enabling kernel mode", "alice", allowedIP)
if err == nil || err.Error() != want {
t.Fatalf("ValidatePeerSubnets error = %v, want %q", err, want)
}
})
}
}
func TestValidatePeerSubnetsAcceptsPeersCoveredByInterfaceSubnets(t *testing.T) {
for _, allowedIP := range []string{"10.8.1.2/32", "10.8.1.2", "10.8.1.2/31"} {
t.Run(allowedIP, func(t *testing.T) {
inst := testInstance()
inst.Address = []string{"10.8.1.5/24"}
inst.Peers[0].AllowedIPs = []string{allowedIP}
if err := ValidatePeerSubnets(inst); err != nil {
t.Fatalf("covered peer was rejected: %v", err)
}
})
}
}
// TestBuildSpecRouteThroughXrayOnlyDeletesNAT: a legacy routeThroughXray row
// must not leave a masquerade rule behind from an earlier save.
func TestBuildSpecRouteThroughXrayOnlyDeletesNAT(t *testing.T) {
s, err := buildSpec(Desired{Instance: testInstance(), EgressInterface: "eth0", RouteThroughXray: true})
if err != nil {
t.Fatal(err)
}
if s.nat != "add table ip xui_awg1\ndelete table ip xui_awg1\n" {
t.Fatalf("NAT transaction = %q, want only the table delete", s.nat)
}
if s.forward != (forwardRules{}) {
t.Fatalf("forward rules = %+v, want none without NAT egress", s.forward)
}
}
// TestBuildSpecRejectsUnsafeInputs: each lands in an argv or an nft
// transaction, and the kernel interface name has a hard 15-byte limit.
func TestBuildSpecRejectsUnsafeInputs(t *testing.T) {
cases := map[string]Desired{
"egress with quote": {Instance: testInstance(), EgressInterface: `eth0" accept; #`},
"pinned listen": {Instance: func() amneziawg.Instance { i := testInstance(); i.Listen = "203.0.113.10"; return i }()},
"ipv6 address": {Instance: func() amneziawg.Instance { i := testInstance(); i.Address = []string{"fd00::1/64"}; return i }(), EgressInterface: "eth0"},
"no address": {Instance: func() amneziawg.Instance { i := testInstance(); i.Address = nil; return i }(), EgressInterface: "eth0"},
"no private key": {Instance: func() amneziawg.Instance { i := testInstance(); i.PrivateKey = ""; return i }(), EgressInterface: "eth0"},
"name over 15 bytes": {Instance: func() amneziawg.Instance { i := testInstance(); i.Id = 123456789; return i }(), EgressInterface: "eth0"},
"non-positive id": {Instance: func() amneziawg.Instance { i := testInstance(); i.Id = 0; return i }(), EgressInterface: "eth0"},
"unparsable address": {Instance: func() amneziawg.Instance { i := testInstance(); i.Address = []string{"10.8.1.1"}; return i }(), EgressInterface: "eth0"},
"egress with newline": {Instance: testInstance(), EgressInterface: "eth0\n"},
}
for name, d := range cases {
t.Run(name, func(t *testing.T) {
if _, err := buildSpec(d); err == nil {
t.Fatal("buildSpec accepted it")
}
})
}
}
// TestBuildSpecMTU: an unset MTU must still be S4-aware, or every full-size
// packet fragments; an explicit one must win.
func TestBuildSpecMTU(t *testing.T) {
inst := testInstance()
inst.MTU = 0
s, err := buildSpec(Desired{Instance: inst, EgressInterface: "eth0"})
if err != nil {
t.Fatal(err)
}
if s.mtu != 1408 {
t.Fatalf("default MTU = %d, want 1408 (1420 minus S4 = 12)", s.mtu)
}
inst.MTU = 1280
if s, _ = buildSpec(Desired{Instance: inst, EgressInterface: "eth0"}); s.mtu != 1280 {
t.Fatalf("explicit MTU = %d, want 1280", s.mtu)
}
}
// TestStructuralFingerprintIgnoresPeersOnly: a peer edit must sync in place,
// while key, port, address or MTU changes need a rebuild.
func TestStructuralFingerprintIgnoresPeersOnly(t *testing.T) {
base, err := buildSpec(Desired{Instance: testInstance(), EgressInterface: "eth0"})
if err != nil {
t.Fatal(err)
}
peerEdit := testInstance()
peerEdit.Peers = peerEdit.Peers[:1]
if s, _ := buildSpec(Desired{Instance: peerEdit, EgressInterface: "eth0"}); s.structFP != base.structFP {
t.Error("a peer edit changed the structural fingerprint")
}
for name, mutate := range map[string]func(*amneziawg.Instance){
"private key": func(i *amneziawg.Instance) { i.PrivateKey = "dPrivateKeyBase64ValueForTestingOnlyPadding0=" },
"listen port": func(i *amneziawg.Instance) { i.ListenPort++ },
"address": func(i *amneziawg.Instance) { i.Address = []string{"10.9.1.1/24"} },
"mtu": func(i *amneziawg.Instance) { i.MTU = 1300 },
"obfuscation": func(i *amneziawg.Instance) { i.Obfuscation.Jc = 7 },
} {
inst := testInstance()
mutate(&inst)
if s, _ := buildSpec(Desired{Instance: inst, EgressInterface: "eth0"}); s.structFP == base.structFP {
t.Errorf("%s change kept the structural fingerprint", name)
}
}
}
// TestOwnershipGrammarRefusesForeignConfigs: only a file Render wrote may be
// adopted, synced or torn down.
func TestOwnershipGrammarRefusesForeignConfigs(t *testing.T) {
name := ownedInterfaceName(1)
conf, err := Render(testInstance())
if err != nil {
t.Fatal(err)
}
cases := map[string]string{
"unmarked": strings.TrimPrefix(conf, ownershipMarker(name)+"\n"),
"other marker": strings.Replace(conf, ownershipMarker(name), ownershipMarker(ownedInterfaceName(2)), 1),
"hook line": strings.Replace(conf, "ListenPort", "PostUp = id\nListenPort", 1),
"wg-quick address": strings.Replace(conf, "ListenPort", "Address = 10.8.1.1/24\nListenPort", 1),
"peer endpoint": conf + "Endpoint = 1.2.3.4:1\n",
"interface comment": strings.Replace(conf, "[Interface]\n", "[Interface]\n# note\n", 1),
"second interface": conf + "[Interface]\n",
"crlf": strings.ReplaceAll(conf, "\n", "\r\n"),
}
for label, body := range cases {
if validOwnedConfig(body, name) {
t.Errorf("%s: ownership grammar accepted a config Render never writes", label)
}
}
}