Files
3x-ui/internal/web/service/xray_amneziawg_outbound_test.go
T
MHSanaei 75f3702dd3 fix(amneziawg): fall back to a free egress port when 64900 is refused
The panel's SOCKS5 egress for AmneziaWG outbounds bound the fixed
127.0.0.1:64900, and every generated socks bridge dialed that constant.
64900 sits inside Windows' dynamic port range, where the OS can reserve
whole blocks (this host excludes 64885-64984), so on the Windows builds
release.yml ships the listener could stay down and every AmneziaWG
outbound with it. Listen now tries 64900 first and falls back to any
free loopback port; bridges, the outbound probe and the port-conflict
check use EgressPort(), the port actually held. Bridges are generated
apart from the listener, so BuildSocksBridge records the port it wrote
and the AmneziaWG job requests an Xray restart while the listener holds
a different one. Where 64900 is free nothing changes.

The job's restart request is two lines of wiring no test reaches; the
staleness it acts on is pinned by
TestBridgesStaleUntilRegeneratedForTheBoundPort.
2026-09-27 16:09:54 +02:00

336 lines
9.7 KiB
Go

package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
func amneziawgnetEgressPortForTest() int { return amneziawgnet.EgressPort() }
func wgKeypairForTest() (priv, pub string, err error) {
return wgutil.GenerateWireguardKeypair()
}
func makeAWGOutboundConfig(t *testing.T) *xray.Config {
t.Helper()
cfg := &xray.Config{}
err := json.Unmarshal([]byte(`{
"outbounds": [
{"protocol": "freedom", "tag": "direct"},
{"protocol": "amneziawg", "tag": "awg-hop", "settings": {"secretKey": "x"}}
]
}`), cfg)
if err != nil {
t.Fatal(err)
}
return cfg
}
// The core folds the protocol id's case before resolving it, so a mixed-case
// spelling must bridge here too or the raw pseudo-protocol reaches the core.
func TestTransformAmneziaWGOutbounds_ReadsTheProtocolIDLikeTheCore(t *testing.T) {
for _, protocol := range []string{"amneziawg", "AmneziaWG", "AMNEZIAWG"} {
t.Run(protocol, func(t *testing.T) {
cfg := &xray.Config{}
raw := `{"outbounds":[
{"protocol":"freedom","tag":"direct"},
{"protocol":"` + protocol + `","tag":"awg-hop","settings":{"secretKey":"x"}}
]}`
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
t.Fatal(err)
}
if err := transformAmneziaWGOutbounds(cfg); err != nil {
t.Fatal(err)
}
var outbounds []struct {
Protocol string `json:"protocol"`
Tag string `json:"tag"`
Settings struct {
Address string `json:"address"`
Port int `json:"port"`
User string `json:"user"`
} `json:"settings"`
}
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
if len(outbounds) != 2 {
t.Fatalf("outbound count = %d, want 2 (no additions or drops)", len(outbounds))
}
got := outbounds[1]
if got.Protocol != "socks" {
t.Errorf("protocol = %q, want %q: the bridge never ran, so the raw pseudo-protocol reaches the core", got.Protocol, "socks")
}
if got.Tag != "awg-hop" {
t.Errorf("tag = %q, want %q", got.Tag, "awg-hop")
}
if got.Settings.Address != "127.0.0.1" || got.Settings.Port != amneziawgnetEgressPortForTest() || got.Settings.User != "awg-hop" {
t.Errorf("settings = %+v, want the socks bridge for tag %q on port %d", got.Settings, "awg-hop", amneziawgnetEgressPortForTest())
}
})
}
}
func TestTransformAmneziaWGOutbounds(t *testing.T) {
cfg := makeAWGOutboundConfig(t)
if err := transformAmneziaWGOutbounds(cfg); err != nil {
t.Fatal(err)
}
var outbounds []struct {
Protocol string `json:"protocol"`
Tag string `json:"tag"`
Settings struct {
Address string `json:"address"`
Port int `json:"port"`
User string `json:"user"`
Pass string `json:"pass"`
} `json:"settings"`
}
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
if len(outbounds) != 2 {
t.Fatalf("outbound count = %d, want 2 (no additions or drops)", len(outbounds))
}
if outbounds[0].Protocol != "freedom" || outbounds[0].Tag != "direct" {
t.Fatalf("first outbound disturbed: %+v", outbounds[0])
}
got := outbounds[1]
if got.Protocol != "socks" {
t.Fatalf("amneziawg outbound not swapped to socks: %q", got.Protocol)
}
if got.Tag != "awg-hop" {
t.Fatalf("tag not preserved: %q", got.Tag)
}
if got.Settings.Address != "127.0.0.1" {
t.Fatalf("bridge address = %q, want 127.0.0.1", got.Settings.Address)
}
if got.Settings.Port != amneziawgnetEgressPortForTest() {
t.Fatalf("bridge port = %d", got.Settings.Port)
}
if got.Settings.User != "awg-hop" {
t.Fatalf("SOCKS username = %q, want the outbound tag", got.Settings.User)
}
if got.Settings.Pass == "" {
t.Fatal("SOCKS password must be set (egress server enforces it)")
}
}
func TestTransformAmneziaWGOutbounds_NoopWithoutAWG(t *testing.T) {
before := &xray.Config{}
if err := json.Unmarshal([]byte(`{"outbounds":[{"protocol":"freedom","tag":"direct"}]}`), before); err != nil {
t.Fatal(err)
}
cfg := &xray.Config{}
if err := json.Unmarshal(before.OutboundConfigs, &cfg.OutboundConfigs); err != nil {
t.Fatal(err)
}
orig := json_util.RawMessage(append([]byte(nil), cfg.OutboundConfigs...))
if err := transformAmneziaWGOutbounds(cfg); err != nil {
t.Fatal(err)
}
if string(cfg.OutboundConfigs) != string(orig) {
t.Fatalf("config without amneziawg outbounds must stay byte-identical:\nbefore=%s\nafter=%s", orig, cfg.OutboundConfigs)
}
}
func TestCheckXrayConfig_AcceptsValidAWGOutbound(t *testing.T) {
// A syntactically valid AWG outbound must pass panel-side validation --
// the Xray-core loader would reject the unknown protocol outright.
priv, pub, err := wgKeypairForTest()
if err != nil {
t.Fatal(err)
}
template := `{
"outbounds": [{
"protocol": "amneziawg",
"tag": "awg-hop",
"settings": {
"mtu": 1420,
"secretKey": "` + priv + `",
"address": ["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": [{
"publicKey": "` + pub + `",
"allowedIPs": ["0.0.0.0/0"],
"endpoint": "203.0.113.7:51820",
"keepAlive": 25
}]
}
}]
}`
svc := &XraySettingService{}
if err := svc.CheckXrayConfig(template); err != nil {
t.Fatalf("valid amneziawg outbound rejected: %v", err)
}
}
func TestCheckXrayConfig_RejectsBrokenAWGOutbound(t *testing.T) {
// The emptied field's partner must be a real key, or the case is decided
// by that partner and stays green with the empty-key guard removed.
priv, pub, err := wgKeypairForTest()
if err != nil {
t.Fatal(err)
}
cases := []struct {
name string
template string
}{
{
name: "not a key",
template: `{
"outbounds": [{
"protocol": "amneziawg",
"tag": "awg-bad",
"settings": {
"secretKey": "not-a-key",
"address": ["10.8.0.2/32"],
"peers": [{"publicKey": "alsobad", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
}
}]
}`,
},
{
name: "empty secretKey",
template: `{
"outbounds": [{
"protocol": "amneziawg",
"tag": "awg-empty-sec",
"settings": {
"secretKey": "",
"address": ["10.8.0.2/32"],
"peers": [{"publicKey": "` + pub + `", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
}
}]
}`,
},
{
name: "empty peer publicKey",
template: `{
"outbounds": [{
"protocol": "amneziawg",
"tag": "awg-empty-pub",
"settings": {
"secretKey": "` + priv + `",
"address": ["10.8.0.2/32"],
"peers": [{"publicKey": "", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
}
}]
}`,
},
}
svc := &XraySettingService{}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if err := svc.CheckXrayConfig(tc.template); err == nil {
t.Fatalf("%s: expected error, got nil", tc.name)
}
})
}
}
func TestTransformAmneziaWGOutbounds_PreservesSiblingKeys(t *testing.T) {
cfg := &xray.Config{}
err := json.Unmarshal([]byte(`{
"outbounds": [
{"protocol": "amneziawg", "tag": "awg-hop", "sendThrough": "0.0.0.0",
"targetStrategy": "UseIPv4",
"mux": {"enabled": false},
"streamSettings": {"sockopt": {"tcpFastOpen": true}},
"settings": {"secretKey": "x"}}
]
}`), cfg)
if err != nil {
t.Fatal(err)
}
if err := transformAmneziaWGOutbounds(cfg); err != nil {
t.Fatal(err)
}
var outbounds []struct {
Protocol string `json:"protocol"`
Tag string `json:"tag"`
SendThrough string `json:"sendThrough"`
TargetStrategy string `json:"targetStrategy"`
Mux map[string]any `json:"mux"`
StreamSettings map[string]any `json:"streamSettings"`
}
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
t.Fatal(err)
}
if len(outbounds) != 1 {
t.Fatalf("outbound count = %d, want 1", len(outbounds))
}
got := outbounds[0]
if got.SendThrough != "0.0.0.0" {
t.Fatalf("sendThrough dropped: %q", got.SendThrough)
}
if got.TargetStrategy != "UseIPv4" {
t.Fatalf("targetStrategy dropped: %q", got.TargetStrategy)
}
if got.Mux == nil {
t.Fatal("mux dropped")
}
if got.StreamSettings == nil {
t.Fatal("streamSettings.sockopt dropped")
}
}
func TestTransformAmneziaWGOutbounds_EmptyTagIsAnError(t *testing.T) {
cfg := &xray.Config{}
if err := json.Unmarshal([]byte(`{
"outbounds": [
{"protocol": "freedom", "tag": "direct"},
{"protocol": "amneziawg", "tag": "", "settings": {"secretKey": "x"}}
]
}`), cfg); err != nil {
t.Fatal(err)
}
if err := transformAmneziaWGOutbounds(cfg); err == nil {
t.Fatal("empty-tag amneziawg outbound must fail config generation, not silently pass through")
}
}
func TestCheckXrayConfig_RejectsEmptyTagAWGOutbound(t *testing.T) {
priv, pub, err := wgKeypairForTest()
if err != nil {
t.Fatal(err)
}
template := `{
"outbounds": [{
"protocol": "amneziawg",
"tag": "",
"settings": {
"secretKey": "` + priv + `",
"address": ["10.8.0.2/32"],
"peers": [{"publicKey": "` + pub + `", "allowedIPs": ["0.0.0.0/0"], "endpoint": "203.0.113.7:51820"}]
}
}]
}`
svc := &XraySettingService{}
if err := svc.CheckXrayConfig(template); err == nil {
t.Fatal("empty-tag amneziawg outbound accepted by CheckXrayConfig")
}
}
func TestCheckXrayConfig_RejectsNonStringTagAWGOutbound(t *testing.T) {
template := `{
"outbounds": [{
"protocol": "amneziawg",
"tag": 123,
"settings": {"secretKey": "x"}
}]
}`
svc := &XraySettingService{}
if err := svc.CheckXrayConfig(template); err == nil {
t.Fatal("non-string tag amneziawg outbound accepted by CheckXrayConfig")
}
}