Files
3x-ui/internal/amneziawg/outbound_test.go
T
Kirill Rudenko 8979072bd9 fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H (#6642)
* fix(amneziawg): bound S1-S3 by the receive buffer, reject overlapping H

The native AmneziaWG validator, both Zod schemas, both forms and the docs now
follow the rules amneziawg-go actually enforces.

S1-S3. A padded handshake message is 148+S1, 92+S2 or 64+S3 bytes
(device/send.go). The peer reads each datagram into a [MaxMessageSize]byte
buffer, where MaxMessageSize = MaxSegmentSize (device/pools.go,
constants.go). MaxSegmentSize is 65535 on Linux/Android, 2016 on Windows and
1700 on iOS (device/queueconstants_*.go). The limits are therefore
S1 <= 1552, S2 <= 1608 and S3 <= 1636. Before, S1/S2 allowed 65535, which
iOS peers silently drop, and S3 was capped at 64, a number inherited from the
coinman-dev/3ax-ui port in #6105 with no stated reason. That cap blocked real
configs such as Amnezia Premium's S3=1045. RandomTrailers only tops a packet
up to 500 bytes (DefaultUdpWindow), so it never pushes a message past these
limits.

H1-H4. amneziawg-go refuses the whole device when the header ranges overlap
("headers must not overlap", device/uapi.go mergeWithDevice), and so does the
kernel module (src/netlink.c). The panel did not check this, so an inbound
with overlapping ranges saved and then failed to apply. A blank H is never
sent, so the engine keeps its default, WireGuard's own type 1-4; the check
treats blank fields that way. The docs said 1-4 "must not be used". They are
valid and are the engine default, only unobfuscated without a
HeaderProtectionKey. The docs also said amneziawg-go rejects S1+56 == S2. It
does not (IpcSet accepts it). The panel keeps that rule as a fingerprint
guard, and the docs now say so.

Tests: the new params_test cases and the Zod bounds fail on the old code.
TestValidatedObfuscationAlwaysApplies runs every accepted set through a real
amneziawg-go IpcSet and now covers overlap, blank-H defaults, H=1-4, the
exact S bounds and the full Amnezia Premium set. Before this fix it failed
with "headers must not overlap".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(amneziawg): bound only inbound padding by the iOS receive buffer

The 1700-byte iOS buffer limits what an inbound's clients can receive,
but ValidateObfuscation also runs for outbounds, and the Xray template
save re-validates every AmneziaWG outbound. An outbound whose remote
server uses S1 above 1552 would have blocked every Xray settings save,
though its values come from that server and are received on Linux.

ValidateObfuscation keeps amneziawg-go's uint16 UAPI width for S1-S3;
ValidateServerObfuscation adds the receive-buffer bounds and is what
inbounds call. The outbound schema and form follow the same split.

---------

Co-authored-by: Kirill Rudenko <rudenko@npp-energy.ru>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
2026-09-26 22:02:49 +02:00

335 lines
10 KiB
Go

package amneziawg
import (
"encoding/json"
"testing"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
)
// validOutboundJSON is a fully valid amneziawg outbound settings payload.
func validOutboundJSON(t *testing.T) []byte {
t.Helper()
raw := map[string]any{
"mtu": 1420,
"secretKey": validPrivKey(t),
"address": []string{"10.8.0.2/32"},
"jc": 4,
"jmin": 40,
"jmax": 100,
"s1": 15,
"s2": 80,
"s3": 12,
"s4": 12,
"h1": "100-800",
"h2": "900-1600",
"h3": "1700-2400",
"h4": "2500-3200",
"peers": []map[string]any{{
"publicKey": validPubKey(t),
"allowedIPs": []string{"0.0.0.0/0", "::/0"},
"endpoint": "203.0.113.7:51820",
"keepAlive": 25,
}},
}
bs, err := json.Marshal(raw)
if err != nil {
t.Fatal(err)
}
return bs
}
func validPubKey(t *testing.T) string {
t.Helper()
_, pub, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatal(err)
}
return pub
}
func validPrivKey(t *testing.T) string {
t.Helper()
priv, _, err := wgutil.GenerateWireguardKeypair()
if err != nil {
t.Fatal(err)
}
return priv
}
func TestInstanceFromOutbound_OK(t *testing.T) {
wrapped, err := json.Marshal(map[string]any{
"protocol": "amneziawg",
"tag": "awg-out-test",
"settings": json.RawMessage(validOutboundJSON(t)),
})
if err != nil {
t.Fatal(err)
}
inst, ok := InstanceFromOutbound("awg-out-test", wrapped)
if !ok {
t.Fatal("InstanceFromOutbound returned false for a valid outbound")
}
if inst.Tag != "awg-out-test" {
t.Fatalf("Tag = %q, want awg-out-test", inst.Tag)
}
if len(inst.Peers) != 1 {
t.Fatalf("len(Peers) = %d, want 1", len(inst.Peers))
}
p := inst.Peers[0]
if p.Endpoint != "203.0.113.7:51820" {
t.Fatalf("Endpoint = %q", p.Endpoint)
}
if p.KeepAlive != 25 {
t.Fatalf("KeepAlive = %d, want 25", p.KeepAlive)
}
if len(p.AllowedIPs) != 2 {
t.Fatalf("AllowedIPs = %v", p.AllowedIPs)
}
if inst.MTU != 1420 {
t.Fatalf("MTU = %d, want 1420", inst.MTU)
}
if inst.Obfuscation.Jc != 4 || inst.Obfuscation.S1 != 15 {
t.Fatalf("Obfuscation not carried: %+v", inst.Obfuscation)
}
}
func TestInstanceFromOutbound_RejectsIncompletePeer(t *testing.T) {
m := validOutboundMapT(t)
m["address"] = []any{}
bs, _ := json.Marshal(m)
wrapped, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs)})
if _, ok := InstanceFromOutbound("t", wrapped); ok {
t.Fatal("expected false when address list is empty")
}
m2 := validOutboundMapT(t)
m2["peers"].([]any)[0].(map[string]any)["endpoint"] = ""
bs2, _ := json.Marshal(m2)
wrapped2, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs2)})
// The only peer is incomplete -> skipped -> zero usable peers -> false,
// mirroring InstanceFromInbound's "nothing to serve" contract.
if _, ok := InstanceFromOutbound("t", wrapped2); ok {
t.Fatal("outbound whose only peer lacks an endpoint must be unusable")
}
// With a second, complete peer the instance stays usable and only the
// broken entry disappears.
m3 := validOutboundMapT(t)
brokenPeer := validOutboundMapT(t)["peers"].([]any)[0].(map[string]any)
brokenPeer["endpoint"] = ""
m3["peers"] = []any{brokenPeer, validOutboundMapT(t)["peers"].([]any)[0]}
bs3, _ := json.Marshal(m3)
wrapped3, _ := json.Marshal(map[string]any{"protocol": "amneziawg", "settings": json.RawMessage(bs3)})
inst, ok := InstanceFromOutbound("t", wrapped3)
if !ok {
t.Fatal("one good peer should keep the outbound usable")
}
if len(inst.Peers) != 1 {
t.Fatalf("broken peer must be dropped; got %d peers", len(inst.Peers))
}
}
func TestValidateAmneziaWGOutbound_AcceptsValidAndRejectsBroken(t *testing.T) {
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(validOutboundJSON(t))); err != nil {
t.Fatalf("valid outbound rejected: %v", err)
}
cases := []struct {
name string
breakF func(m map[string]any)
}{
{"empty secretKey", func(m map[string]any) { m["secretKey"] = "" }},
{"empty peer publicKey", func(m map[string]any) { peer(m)["publicKey"] = "" }},
{"whitespace secretKey", func(m map[string]any) { m["secretKey"] = " " }},
{"whitespace peer publicKey", func(m map[string]any) { peer(m)["publicKey"] = " " }},
{"bad endpoint no port", func(m map[string]any) { peer(m)["endpoint"] = "203.0.113.7" }},
{"endpoint control char", func(m map[string]any) { peer(m)["endpoint"] = "host:51820\nPostUp=x" }},
{"empty allowedIPs", func(m map[string]any) { peer(m)["allowedIPs"] = []string{} }},
{"no peers", func(m map[string]any) { m["peers"] = []any{} }},
{"bad allowedIP", func(m map[string]any) { peer(m)["allowedIPs"] = []string{"not-a-prefix"} }},
{"no address", func(m map[string]any) { m["address"] = []any{} }},
{"bad jc/jmin order", func(m map[string]any) { m["jmin"] = 200; m["jmax"] = 100 }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := validOutboundMapT(t)
tc.breakF(m)
bs, _ := json.Marshal(m)
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
t.Fatalf("%s: expected error, got nil", tc.name)
}
})
}
}
// wrapOutboundSettings embeds a settings payload the way the template stores
// it: as the nested "settings" of an amneziawg outbound row.
func wrapOutboundSettings(settings json.RawMessage) []byte {
bs, err := json.Marshal(map[string]any{
"protocol": "amneziawg",
"tag": "t",
"settings": settings,
})
if err != nil {
panic(err)
}
return bs
}
func peer(m map[string]any) map[string]any {
return m["peers"].([]any)[0].(map[string]any)
}
func validOutboundMapT(t *testing.T) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(validOutboundJSON(t), &m); err != nil {
t.Fatal(err)
}
return m
}
func TestIsAmneziaWGOutbound(t *testing.T) {
yes := []byte(`{"protocol":"amneziawg","tag":"x"}`)
if !IsAmneziaWGOutbound(yes) {
t.Fatal("amneziawg protocol not detected")
}
no := []byte(`{"protocol":"freedom","tag":"x"}`)
if IsAmneziaWGOutbound(no) {
t.Fatal("freedom misdetected as amneziawg")
}
if IsAmneziaWGOutbound([]byte(`{broken`)) {
t.Fatal("garbage misdetected as amneziawg")
}
}
// A blank line terminates IpcSetOperation, silently truncating the peer set;
// validation rejects a trailing newline, parsing normalizes it away.
func TestValidateAmneziaWGOutbound_RejectsAllowedIPWithNewline(t *testing.T) {
m := validOutboundMapT(t)
peer(m)["allowedIPs"] = []string{"0.0.0.0/0\n", "::/0"}
bs, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
t.Fatal("allowedIP with trailing newline: expected error, got nil")
}
}
func TestInstanceFromOutbound_NormalizesAllowedIPs(t *testing.T) {
m := validOutboundMapT(t)
peer(m)["allowedIPs"] = []string{" 0.0.0.0/0\n", "::/0"}
bs, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
inst, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs))
if !ok {
t.Fatal("InstanceFromOutbound returned false for trimmable allowedIPs")
}
got := inst.Peers[0].AllowedIPs
want := []string{"0.0.0.0/0", "::/0"}
if len(got) != len(want) {
t.Fatalf("AllowedIPs = %v, want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("AllowedIPs[%d] = %q, want %q (newline must not survive)", i, got[i], want[i])
}
}
}
func TestInstanceFromOutbound_RejectsUnparseableAllowedIP(t *testing.T) {
m := validOutboundMapT(t)
peer(m)["allowedIPs"] = []string{"not-a-prefix"}
bs, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
if _, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs)); ok {
t.Fatal("unparseable allowedIP must make InstanceFromOutbound return false")
}
}
func TestValidateAmneziaWGOutbound_RejectsControlCharInIParams(t *testing.T) {
for _, field := range []string{"i1", "i2", "i3", "i4", "i5"} {
t.Run(field, func(t *testing.T) {
m := validOutboundMapT(t)
m[field] = "<r 64>\nPostUp=x"
bs, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
t.Fatalf("%s with embedded newline: expected error, got nil", field)
}
})
}
}
func TestValidateAmneziaWGOutbound_RejectsEmptyTag(t *testing.T) {
raw := []byte(`{"protocol":"amneziawg","tag":"","settings":{"secretKey":"x"}}`)
for _, tag := range []string{"", " "} {
if err := ValidateAmneziaWGOutbound(tag, raw); err == nil {
t.Fatalf("tag %q accepted", tag)
}
}
}
func TestValidateAmneziaWGOutbound_DNSField(t *testing.T) {
valid := map[string]string{
"": "",
"1.1.1.1": "1.1.1.1:53",
"8.8.8.8:53": "8.8.8.8:53",
"2606:4700:4700::1111": "[2606:4700:4700::1111]:53",
"[2606:4700:4700::1111]:53": "[2606:4700:4700::1111]:53",
}
for d, expected := range valid {
m := validOutboundMapT(t)
if d != "" {
m["dns"] = d
}
bs, _ := json.Marshal(m)
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err != nil {
t.Fatalf("valid dns %q rejected: %v", d, err)
}
inst, ok := InstanceFromOutbound("t", wrapOutboundSettings(bs))
if !ok || inst.DNS != expected {
t.Fatalf("InstanceFromOutbound dns=%q, want %q", inst.DNS, expected)
}
}
invalid := []string{"not-an-ip", "1.1.1.1\nPostUp=x", "999.999.999.999"}
for _, d := range invalid {
m := validOutboundMapT(t)
m["dns"] = d
bs, _ := json.Marshal(m)
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
t.Fatalf("invalid dns %q accepted", d)
}
}
}
// An outbound's S values are dictated by the remote server and received here on
// Linux, so the iOS receive-buffer cap on inbounds must not refuse them.
func TestValidateAmneziaWGOutbound_AcceptsRemotePaddingPastTheIOSCap(t *testing.T) {
m := validOutboundMapT(t)
m["s1"], m["s2"], m["s3"] = 2000, 3000, 4000
bs, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err != nil {
t.Fatalf("remote server padding S1=2000 S2=3000 S3=4000 refused: %v", err)
}
m["s1"] = 65536
if bs, err = json.Marshal(m); err != nil {
t.Fatal(err)
}
if err := ValidateAmneziaWGOutbound("t", wrapOutboundSettings(bs)); err == nil {
t.Fatal("S1=65536 is past amneziawg-go's uint16 UAPI width and must be refused")
}
}