mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-16 15:17:14 +00:00
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>
This commit is contained in:
@@ -221,7 +221,7 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
|
||||
// dial neither proves reachability nor measures latency. Such outbounds
|
||||
// must go through the real xray handshake probe instead.
|
||||
func outboundTransportIsUDP(ob map[string]any) bool {
|
||||
if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" {
|
||||
if protocol, _ := ob["protocol"].(string); protocol == "hysteria" || protocol == "wireguard" || protocol == "amneziawg" {
|
||||
return true
|
||||
}
|
||||
if stream, ok := ob["streamSettings"].(map[string]any); ok {
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
@@ -384,6 +385,33 @@ func buildBatchTestConfig(items []*httpBatchItem, allOutbounds []any, ports []in
|
||||
outbounds = append(outbounds, it.outbound)
|
||||
}
|
||||
}
|
||||
// Bridge amneziawg entries like GetXrayConfig does -- one raw entry fails
|
||||
// the whole temp config; drop unbridgeable ones, not unrelated items.
|
||||
bridged := make([]any, 0, len(outbounds))
|
||||
for _, ob := range outbounds {
|
||||
m, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
bridged = append(bridged, ob)
|
||||
continue
|
||||
}
|
||||
if p, _ := m["protocol"].(string); p != "amneziawg" {
|
||||
bridged = append(bridged, ob)
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
repl, ok := amneziawgnet.BuildSocksBridge(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var replacement any
|
||||
if json.Unmarshal(repl, &replacement) == nil {
|
||||
bridged = append(bridged, replacement)
|
||||
}
|
||||
}
|
||||
outbounds = bridged
|
||||
for _, ob := range outbounds {
|
||||
outbound, ok := ob.(map[string]any)
|
||||
if !ok {
|
||||
|
||||
@@ -557,6 +557,33 @@ func TestTestOutboundsTCPModeForcesUDPToHTTPProbe(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestOutboundsTCPModeForcesAmneziaWGToHTTPProbe(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
withStubProcess(t, func(cfg *xray.Config, configPath string) batchProcess {
|
||||
return &stubProcess{cfg: cfg, serveSocks: true}
|
||||
})
|
||||
withEgressTraceProbe(t, func(*url.URL) *TestEgressResult {
|
||||
return &TestEgressResult{IPv4: "198.51.100.2", Country: "ZZ", Warp: "off"}
|
||||
})
|
||||
|
||||
batch := mustJSON(t, []any{map[string]any{"tag": "awg", "protocol": "amneziawg"}})
|
||||
results, err := (&OutboundService{}).TestOutbounds(batch, srv.URL, "", "tcp")
|
||||
if err != nil {
|
||||
t.Fatalf("TestOutbounds: %v", err)
|
||||
}
|
||||
r := results[0]
|
||||
if !r.Success || r.Mode != "http" {
|
||||
t.Errorf("amneziawg outbound in tcp mode = %+v, want success with mode %q", r, "http")
|
||||
}
|
||||
if r.Egress == nil || r.Egress.IPv4 != "198.51.100.2" {
|
||||
t.Errorf("amneziawg outbound egress = %+v", r.Egress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeModeLabel(t *testing.T) {
|
||||
cases := []struct{ mode, want string }{
|
||||
{"tcp", "tcp"},
|
||||
|
||||
@@ -185,6 +185,18 @@ func checkPortConflictTx(db *gorm.DB, inbound *model.Inbound, ignoreId int) (*po
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Egress SOCKS server holds loopback EgressBasePort when AWG outbounds are
|
||||
// active; conflict check prevents inbounds from colliding with it.
|
||||
if inbound.NodeID == nil && inbound.Port == int(amneziawgnet.EgressBasePort) &&
|
||||
newBits&transportTCP != 0 && listenOverlaps("127.0.0.1", inbound.Listen) {
|
||||
return &portConflictDetail{
|
||||
Tag: "amneziawg-egress",
|
||||
Listen: "127.0.0.1",
|
||||
Port: inbound.Port,
|
||||
Transports: transportTCP,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Every enabled local AmneziaWG inbound gets its own automatic Xray
|
||||
// SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a
|
||||
// port derived purely from its id (amneziawgnet.SOCKSPortForInbound) --
|
||||
|
||||
@@ -739,10 +739,27 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) {
|
||||
// it's now ignored -- see the "RouteThroughXrayOff" test below.
|
||||
const amneziawgRoutedSettings = `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24,"routeThroughXray":true},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`
|
||||
|
||||
// An enabled AmneziaWG inbound's automatic Xray SOCKS5 relay inbound
|
||||
// (injectAmneziawgnetSocks) is a synthetic loopback inbound, not a database
|
||||
// row, so checkPortConflict needs its own check to catch a collision --
|
||||
// exactly the same shape of problem as the reserved API port above.
|
||||
// A local TCP inbound on EgressBasePort must conflict with the AmneziaWG
|
||||
// egress SOCKS server (which is not in the database).
|
||||
func TestCheckPortConflict_EgressPortBlockedLocal(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
|
||||
svc := &InboundService{}
|
||||
candidate := &model.Inbound{
|
||||
Tag: "vless-bridge",
|
||||
Listen: "0.0.0.0",
|
||||
Port: int(amneziawgnet.EgressBasePort),
|
||||
Protocol: model.VLESS,
|
||||
}
|
||||
got, err := svc.checkPortConflict(candidate, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("checkPortConflict: %v", err)
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("a local inbound on the egress port %d must conflict", amneziawgnet.EgressBasePort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) {
|
||||
setupConflictDB(t)
|
||||
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings)
|
||||
|
||||
@@ -161,6 +161,11 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) {
|
||||
// still carry sessionPlacement/sessionKey; lift them too (same reason as
|
||||
// the per-inbound lift below).
|
||||
xrayConfig.OutboundConfigs = liftOutboundsXhttpSessionIDKeys(xrayConfig.OutboundConfigs)
|
||||
// Bridge amneziawg outbounds before anything else reads OutboundConfigs;
|
||||
// the core has no amneziawg proxy and would reject the raw entry.
|
||||
if err := transformAmneziaWGOutbounds(xrayConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, _, _ = s.inboundService.AddTraffic(nil, nil)
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet"
|
||||
json_util "github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
|
||||
// transformAmneziaWGOutbounds swaps each template "amneziawg" outbound for
|
||||
// its socks bridge; unbridgeable entries fail generation rather than skip.
|
||||
func transformAmneziaWGOutbounds(cfg *xray.Config) error {
|
||||
if len(cfg.OutboundConfigs) == 0 {
|
||||
return nil
|
||||
}
|
||||
var outbounds []json.RawMessage
|
||||
if err := json.Unmarshal(cfg.OutboundConfigs, &outbounds); err != nil {
|
||||
return err
|
||||
}
|
||||
changed := false
|
||||
for i, raw := range outbounds {
|
||||
if !amneziawg.IsAmneziaWGOutbound(raw) {
|
||||
continue
|
||||
}
|
||||
var probe struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
tagErr := json.Unmarshal(raw, &probe)
|
||||
replacement, ok := amneziawgnet.BuildSocksBridge(raw)
|
||||
if !ok {
|
||||
if tagErr != nil {
|
||||
return fmt.Errorf("amneziawg outbound %d: unreadable tag: %w", i, tagErr)
|
||||
}
|
||||
return fmt.Errorf("amneziawg outbound %d (%q): cannot bridge: tag must be a non-empty string", i, probe.Tag)
|
||||
}
|
||||
outbounds[i] = replacement
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
bs, err := json.Marshal(outbounds)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg.OutboundConfigs = json_util.RawMessage(bs)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
||||
)
|
||||
@@ -58,6 +59,20 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||
coreVersion = process.GetXrayVersion()
|
||||
}
|
||||
for _, outbound := range outbounds {
|
||||
// Panel pseudo-protocol: validated panel-side because the core's
|
||||
// loader would reject it outright.
|
||||
if amneziawg.IsAmneziaWGOutbound(outbound) {
|
||||
var probe struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal(outbound, &probe); err != nil {
|
||||
return common.NewError("xray template config invalid: amneziawg outbound tag unreadable:", err)
|
||||
}
|
||||
if err := amneziawg.ValidateAmneziaWGOutbound(probe.Tag, outbound); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := xray.ValidateOutboundConfig(outbound); err != nil {
|
||||
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) {
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user