mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
d5ab84e8d5
* feat(amneziawg): add AmneziaWG as an outbound protocol - AmneziaWG outbound protocol end-to-end: config schema, socks bridge, netstack, panel UI - Route amneziawg outbounds to HTTP probe in TCP mode (backend + frontend classifiers) with pinning test - Add 2-minute idle read deadline to pumpUDPEgress to reap idle egress sessions - Require SOCKS5 username/password auth on the egress server (reject NO-AUTH with 0xFF) with test - Bound the egress TCP tunnel dial with portForwardDialTimeout (10s), matching portfwd.go - Resolve UDP domain targets off the association's reader loop via deliverUDPDatagram; race-safe getOrDial starts the reply pump at session creation; client passed by value into resolver goroutines (pinned by TestEgressUDPDatagramDomainInterleavedClients) - Reconcile early-returns on an empty desired set and closes the egress listener; EgressBasePort (64900) is reserved against local inbound port conflicts like the internal API port, with pinning tests for both the port reservation (TestCheckPortConflict_EgressPortBlockedLocal) and the Reconcile empty-desired Close/Listen lifecycle (TestOutboundManagerReconcileEmptyDesiredClosesEgress) - Eliminate acceptLoop shutdown race by validating listener != nil and registering to tracked under s.mu before wg.Add; bound pre-auth handshake with deadline (pinned by TestEgressServerCloseDuringConcurrentAccepts) - Support AAAA and dual-stack domain resolution in tunnel DNS resolver with v6 default fallback (DefaultTunnelDNSServerV6); add DNS field to frontend protocol form; avoid unneeded cache flushes on unchanged SetStack ticks * fix(amneziawg): resolve IPv6-only DNS default fallback and validate required keys - Default to IPv6 tunnel DNS on IPv6-only outbounds with blank dns - Require non-empty secretKey and peer publicKey in ValidateAmneziaWGOutbound - Add end-to-end IPv6 tunnel domain resolution test and test empty key rejection - Trim comment blocks exceeding 2 lines across modified files - Fix Storybook test execution on environments with POSIX locale Co-Authored-By: Claude Code <noreply@anthropic.com> --------- Co-authored-by: rqzbeh <rqzbeh@users.noreply.github.com> Co-authored-by: Claude Code <noreply@anthropic.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
313 lines
9.5 KiB
Go
313 lines
9.5 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)
|
|
}
|
|
}
|
|
}
|