Files
3x-ui/internal/web/service/xray_amneziawg_outbound_test.go
T
Rouzbeh† d5ab84e8d5 feat(amneziawg): add AmneziaWG as an outbound protocol (#6320)
* 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>
2026-09-10 14:50:48 +02:00

290 lines
8.0 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.EgressBasePort }
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
}
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")
}
}