diff --git a/internal/amneziawgnet/socks_config.go b/internal/amneziawgnet/socks_config.go new file mode 100644 index 000000000..da8c7d0f8 --- /dev/null +++ b/internal/amneziawgnet/socks_config.go @@ -0,0 +1,55 @@ +package amneziawgnet + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "sync" +) + +// SOCKSBasePort is the first loopback port used for an AmneziaWG inbound's +// own Xray SOCKS5 relay inbound (see relay.go/SocksInboundSettings). Its own +// range, distinct from amneziawg.EgressBasePort (63100, the kernel-module +// path's TPROXY bridge port) so the two can never collide even if both +// happen to be reachable during a transition. +const SOCKSBasePort = 65100 + +// SOCKSPortForInbound returns the loopback port of one AmneziaWG inbound's +// own Xray SOCKS5 relay inbound, derived deterministically from its id so +// the config-generation code (which builds the inbound) and the relay code +// (which dials it) never have to agree on a runtime-negotiated value -- +// mirrors amneziawg.EgressPortForInbound's own reasoning exactly. +func SOCKSPortForInbound(inboundID int) int { + return SOCKSBasePort + inboundID +} + +var ( + socksPasswordOnce sync.Once + socksPassword string +) + +// SocksPassword returns the process-wide password used to authenticate into +// every AmneziaWG SOCKS5 relay inbound, generating and caching it once +// (lazily, on first use) rather than persisting it anywhere: this traffic +// never leaves loopback, both the config generator (SocksInboundSettings' +// caller) and the relay dialer (SocksRelay/UDPRelay) live in this same +// process, and Xray's own generated config is already rebuilt from scratch +// on every reconcile -- there is nothing for a stored value to survive +// across that a fresh one wouldn't equally satisfy. Not a real secret (see +// SocksRelay's own doc comment); this only needs to be unpredictable enough +// that nothing outside this process could plausibly guess it and dial in +// over loopback. +func SocksPassword() string { + socksPasswordOnce.Do(func() { + var b [24]byte + if _, err := rand.Read(b[:]); err != nil { + // crypto/rand failing is effectively unrecoverable for a + // process that generates real WireGuard keys elsewhere too; + // a fixed fallback keeps this from panicking outright. + socksPassword = fmt.Sprintf("amneziawgnet-fallback-%x", b) + return + } + socksPassword = base64.RawURLEncoding.EncodeToString(b[:]) + }) + return socksPassword +} diff --git a/internal/web/service/port_conflict.go b/internal/web/service/port_conflict.go index eda27ef76..80c6f81ff 100644 --- a/internal/web/service/port_conflict.go +++ b/internal/web/service/port_conflict.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/util/common" @@ -177,15 +178,15 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) } // Every enabled local AmneziaWG inbound gets its own automatic Xray - // bridge (see injectAmneziawgEgress) on 127.0.0.1 at a port derived - // purely from its id (amneziawg.EgressPortForInbound) -- like the - // internal Xray API inbound above, that bridge is not itself a database - // row, so the ordinary DB-backed query below can never see it. Without - // this check, an unrelated inbound saved onto that exact port silently - // fails at the next Xray start, taking every other protocol down with - // it, not just AmneziaWG. + // SOCKS5 relay inbound (see injectAmneziawgnetSocks) on 127.0.0.1 at a + // port derived purely from its id (amneziawgnet.SOCKSPortForInbound) -- + // like the internal Xray API inbound above, that relay inbound is not + // itself a database row, so the ordinary DB-backed query below can never + // see it. Without this check, an unrelated inbound saved onto that exact + // port silently fails at the next Xray start, taking every other + // protocol down with it, not just AmneziaWG. if inbound.NodeID == nil && listenOverlaps("127.0.0.1", inbound.Listen) { - conflict, err := s.checkAmneziawgEgressConflict(inbound, ignoreId, newBits) + conflict, err := s.checkAmneziawgnetSocksConflict(inbound, ignoreId, newBits) if err != nil { return nil, err } @@ -229,15 +230,16 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int) return nil, nil } -// checkAmneziawgEgressConflict reports whether inbound's own port collides -// with an existing, enabled local AmneziaWG inbound's automatic Xray bridge -// port. Only inbounds that actually have RouteThroughXray on ever get a -// bridge (see injectAmneziawgEgress); the others' "reserved" port isn't -// really reserved, so they must not be flagged. ignoreId excludes one -// inbound id from the AmneziaWG candidates, the same way the general -// DB-backed conflict query above excludes the inbound being edited from -// matching itself. -func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) { +// checkAmneziawgnetSocksConflict reports whether inbound's own port +// collides with an existing, enabled local AmneziaWG inbound's automatic +// Xray SOCKS5 relay port. Unlike the retired kernel-module bridge this +// checks every qualifying AmneziaWG inbound unconditionally: the embedded +// relay has no RouteThroughXray-style opt-in, every one of them gets a +// relay inbound (see injectAmneziawgnetSocks). ignoreId excludes one inbound +// id from the AmneziaWG candidates, the same way the general DB-backed +// conflict query above excludes the inbound being edited from matching +// itself. +func (s *InboundService) checkAmneziawgnetSocksConflict(inbound *model.Inbound, ignoreId int, newBits transportBits) (*portConflictDetail, error) { db := database.GetDB() var candidates []*model.Inbound q := db.Model(model.Inbound{}).Where("protocol = ? AND enable = ? AND node_id IS NULL", model.AmneziaWG, true) @@ -248,11 +250,10 @@ func (s *InboundService) checkAmneziawgEgressConflict(inbound *model.Inbound, ig return nil, err } for _, c := range candidates { - inst, ok := amneziawg.InstanceFromInbound(c) - if !ok || !inst.RouteThroughXray { + if _, ok := amneziawg.InstanceFromInbound(c); !ok { continue } - if amneziawg.EgressPortForInbound(c.Id) != inbound.Port { + if amneziawgnet.SOCKSPortForInbound(c.Id) != inbound.Port { continue } return &portConflictDetail{ diff --git a/internal/web/service/port_conflict_test.go b/internal/web/service/port_conflict_test.go index df9dd0103..7669a2f7c 100644 --- a/internal/web/service/port_conflict_test.go +++ b/internal/web/service/port_conflict_test.go @@ -8,7 +8,7 @@ import ( "github.com/op/go-logging" - "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -732,16 +732,18 @@ func TestCheckPortConflict_ReservedAPIPortUDPCoexists(t *testing.T) { } // amneziawgRoutedSettings builds a minimal but complete AmneziaWG settings -// blob with one qualifying, enabled peer and RouteThroughXray on -- the -// shape that actually makes injectAmneziawgEgress (and therefore -// checkAmneziawgEgressConflict) create a bridge at all. +// blob with one qualifying, enabled peer -- the shape that makes +// injectAmneziawgnetSocks (and therefore checkAmneziawgnetSocksConflict) +// create a relay inbound at all. The routeThroughXray field is kept in the +// JSON (a stale value from a pre-cutover install) specifically to prove +// 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 bridge (injectAmneziawgEgress) -// is a synthetic loopback dokodemo-door 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. -func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { +// 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. +func TestCheckPortConflict_AmneziawgnetSocksRelayBlockedLocal(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings) @@ -749,13 +751,13 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { t.Fatalf("read seeded row: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "vless-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, } got, err := svc.checkPortConflict(candidate, 0) @@ -763,7 +765,7 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { t.Fatalf("checkPortConflict: %v", err) } if got == nil { - t.Fatalf("a local inbound on the AmneziaWG bridge port %d must conflict", bridgePort) + t.Fatalf("a local inbound on the AmneziaWG relay port %d must conflict", relayPort) } if msg := got.String(); !strings.Contains(msg, "awg-1") { t.Fatalf("conflict message should name the owning AmneziaWG inbound; got %q", msg) @@ -771,9 +773,9 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) { } // Nodes run their own Xray, so a node inbound landing on the central panel's -// AmneziaWG bridge port must be allowed -- the bridge only ever binds +// AmneziaWG relay port must be allowed -- the relay inbound only ever binds // 127.0.0.1 on the local panel's own Xray. -func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) { +func TestCheckPortConflict_AmneziawgnetSocksRelayAllowedOnNode(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings) @@ -781,37 +783,37 @@ func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(t *testing.T) { if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { t.Fatalf("read seeded row: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "node-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, NodeID: new(1), } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { - t.Fatalf("a node inbound on the local AmneziaWG bridge port must be allowed; got=%v err=%v", got, err) + t.Fatalf("a node inbound on the local AmneziaWG relay port must be allowed; got=%v err=%v", got, err) } } -// A disabled AmneziaWG inbound never gets a bridge injected -// (injectAmneziawgEgress skips !inbound.Enable), so its "reserved" port must -// not block anything. -func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T) { +// A disabled AmneziaWG inbound never gets a relay inbound injected +// (injectAmneziawgnetSocks skips !inbound.Enable), so its "reserved" port +// must not block anything. +func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenDisabled(t *testing.T) { setupConflictDB(t) awg := &model.Inbound{Tag: "awg-1", Enable: false, Listen: "0.0.0.0", Port: 51820, Protocol: model.AmneziaWG, Settings: `{}`} if err := database.GetDB().Create(awg).Error; err != nil { t.Fatalf("seed disabled awg inbound: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awg.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awg.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "vless-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { @@ -819,11 +821,41 @@ func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenDisabled(t *testing.T } } -// An enabled AmneziaWG inbound with RouteThroughXray off never gets a bridge -// injected either (injectAmneziawgEgress requires it), so its port isn't -// reserved -- an inbound created with the default settings, not just an -// explicitly disabled one, must not block anything. -func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenRouteThroughXrayOff(t *testing.T) { +// Unlike the retired kernel-module bridge, the embedded relay has no +// RouteThroughXray-style opt-in -- every qualifying AmneziaWG inbound +// reserves its relay port regardless of that (now-vestigial) field's value, +// including a stale routeThroughXray:true left over from a pre-cutover +// install (amneziawgRoutedSettings). +func TestCheckPortConflict_AmneziawgnetSocksRelayReservedRegardlessOfLegacyRouteThroughXrayField(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{"server":{"privateKey":"priv","publicKey":"pub","subnetIp":"10.8.1.0","subnetCidr":24},"clients":[{"email":"a@x","enable":true,"publicKey":"pub-a","allowedIPs":["10.8.1.2/32"]}]}`) + + var awgInbound model.Inbound + if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) + + svc := &InboundService{} + candidate := &model.Inbound{ + Tag: "vless-bridge", + Listen: "0.0.0.0", + Port: relayPort, + Protocol: model.VLESS, + } + got, err := svc.checkPortConflict(candidate, 0) + if err != nil { + t.Fatalf("checkPortConflict: %v", err) + } + if got == nil { + t.Fatalf("an enabled, qualifying AmneziaWG inbound must reserve its relay port even with RouteThroughXray left at its default") + } +} + +// A qualifying AmneziaWG inbound with no enabled/valid peer at all never +// gets a relay inbound (amneziawg.InstanceFromInbound returns ok=false), so +// its port isn't reserved. +func TestCheckPortConflict_AmneziawgnetSocksRelayIgnoredWhenNoQualifyingPeer(t *testing.T) { setupConflictDB(t) seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`) @@ -831,24 +863,24 @@ func TestCheckPortConflict_AmneziawgEgressBridgeIgnoredWhenRouteThroughXrayOff(t if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil { t.Fatalf("read seeded row: %v", err) } - bridgePort := amneziawg.EgressPortForInbound(awgInbound.Id) + relayPort := amneziawgnet.SOCKSPortForInbound(awgInbound.Id) svc := &InboundService{} candidate := &model.Inbound{ Tag: "vless-bridge", Listen: "0.0.0.0", - Port: bridgePort, + Port: relayPort, Protocol: model.VLESS, } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { - t.Fatalf("an AmneziaWG inbound with RouteThroughXray off must not reserve its bridge port; got=%v err=%v", got, err) + t.Fatalf("an AmneziaWG inbound with no qualifying peer must not reserve its relay port; got=%v err=%v", got, err) } } -// An unrelated port never conflicts with the bridge. -func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing.T) { +// An unrelated port never conflicts with the relay inbound. +func TestCheckPortConflict_AmneziawgnetSocksRelayDifferentPortAllowed(t *testing.T) { setupConflictDB(t) - seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`) + seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, amneziawgRoutedSettings) svc := &InboundService{} candidate := &model.Inbound{ @@ -858,6 +890,6 @@ func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing. Protocol: model.VLESS, } if got, err := svc.checkPortConflict(candidate, 0); err != nil || got != nil { - t.Fatalf("an unrelated port must not conflict with the AmneziaWG bridge; got=%v err=%v", got, err) + t.Fatalf("an unrelated port must not conflict with the AmneziaWG relay inbound; got=%v err=%v", got, err) } } diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 2eccd35a5..fd76ed855 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -367,14 +368,16 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { injectMtprotoEgress(xrayConfig, inbound) } - // Route opted-in AmneziaWG peers through the core's router. Unlike mtg, - // AmneziaWG has no sidecar process of its own making outbound connections - // to dial through a bridge — it's a kernel tunnel interface, so the host - // side (internal/amneziawg's defaultPostUpDown) TPROXYs each opted-in - // peer's traffic to one loopback bridge shared by every AmneziaWG - // instance; this call is what creates that bridge and, per peer, the - // routing rule matching its preserved source IP to its chosen outbound. - injectAmneziawgEgress(xrayConfig, inbounds) + // Every AmneziaWG inbound is embedded (internal/amneziawgnet: amneziawg-go + // over a gVisor netstack, no kernel module) and relays every peer's + // decapsulated traffic into its own loopback SOCKS5 inbound, always on — + // unlike mtproto's bridge above, there's no opt-in gate here: once + // traffic is decapsulated in gVisor, Xray's own freedom outbound is the + // only way it reaches the real internet at all, not an optional extra + // hop. Whether it goes anywhere beyond Xray's default routing is up to + // whatever rules the admin adds through the stock Routing page, exactly + // like routing any other protocol. + injectAmneziawgnetSocks(xrayConfig, inbounds) // Wire the panel's own HTTP traffic through the configured outbound, after // the subscription merge so subscription outbound tags are valid targets. @@ -670,61 +673,40 @@ func injectMtprotoEgress(cfg *xray.Config, inbound *model.Inbound) { }) } -// amneziawgEgressDokodemoSettings is the dokodemo-door settings block for the -// shared AmneziaWG TPROXY bridge: accept both TCP and UDP, and (per this -// fork's existing "Tunnel" protocol convention — see -// frontend/src/lib/xray/inbound-tag.ts) use followRedirect mode so the -// destination comes from the TPROXY-preserved original address rather than a -// fixed port/address pair. -const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedirect":true}` - -// amneziawgEgressStreamSettings turns the bridge's listening socket into a -// TPROXY target, matching internal/amneziawg's iptables `-j TPROXY` rules — -// without this, the kernel-redirected packets never reach a listening -// socket. -const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}` - -// amneziawgEgressSniffingSettings enables sniffing on the bridge, matching -// this fork's own normal per-inbound default (see default.json's "mixed" -// inbound). Without this, domain-based Routing rules can never match a -// single byte of RouteThroughXray traffic: an AmneziaWG peer resolves DNS +// amneziawgEgressSniffingSettings enables sniffing on the AmneziaWG SOCKS5 +// relay inbound, matching this fork's own normal per-inbound default (see +// default.json's "mixed" inbound). Without this, domain-based Routing rules +// can never match a single byte of AmneziaWG traffic: a peer resolves DNS // itself, through the tunnel, before ever sending a packet — by the time -// TPROXY hands the decapsulated traffic to this bridge, the destination is -// already a bare IP, with no domain name attached at the network layer at -// all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host / -// QUIC) the same way it already does for every other inbound; without it, -// only tag/IP/network-based rules can ever match this bridge's traffic, -// and any domain rule above it in the list is silently unreachable. +// the embedded forwarder recovers the decapsulated traffic, the destination +// is already a bare IP, with no domain name attached at the network layer +// at all. Sniffing recovers it from the payload itself (TLS SNI / HTTP Host +// / QUIC) the same way it already does for every other inbound; without +// it, only tag/IP/network-based rules can ever match this traffic, and any +// domain rule above it in the list is silently unreachable. const amneziawgEgressSniffingSettings = `{"enabled":true,"destOverride":["http","tls","quic","fakedns"]}` -// injectAmneziawgEgress gives every enabled, RouteThroughXray-opted-in -// AmneziaWG inbound with at least one qualifying peer its own loopback -// dokodemo-door bridge — tagged with that inbound's own real tag, so it's -// already selectable in the panel's stock Routing page's inbound-tag -// picker, exactly the way an mtproto inbound's own bridge already is (see -// injectMtprotoEgress): the picker's tag list comes from -// InboundService.GetInboundTags(), a plain, -// protocol-blind SELECT over every inbound row's tag, so reusing a real -// inbound's own tag needs no dedicated UI plumbing at all. +// injectAmneziawgnetSocks gives every enabled AmneziaWG inbound with at +// least one qualifying peer its own loopback SOCKS5 inbound for the +// embedded (amneziawg-go) relay path (internal/amneziawgnet) -- always on, +// unlike injectAmneziawgEgress's opt-in RouteThroughXray bridge above, since +// there is no alternative datapath once traffic is decapsulated in gVisor: +// Xray's own freedom outbound is how it reaches the real internet at all +// (see internal/amneziawgnet/relay.go's doc comment, Finding 3 of the +// migration plan). Tagged with the inbound's own real tag, for the same two +// reasons injectAmneziawgEgress already is: it's already selectable in the +// panel's stock Routing page (InboundService.GetInboundTags is +// protocol-blind), and per-inbound traffic totals +// (internal/web/service/inbound_traffic.go's addClientTraffic) match by +// exact tag -- reusing it isn't a style choice. // -// RouteThroughXray is a per-inbound opt-in, off by default: when it's off, -// no bridge is created at all and the tunnel has no Xray dependency -// whatsoever. When it's on, every peer's traffic lands on the bridge — -// internal/amneziawg's defaultPostUpDown TPROXYs it there, there is no -// further per-peer opt-in — but this function never generates a routing -// rule of its own. Whether that traffic goes anywhere beyond Xray's default -// routing is entirely up to whatever rules the admin adds through that same -// stock Routing page (inboundTag + an optional sourceIP to target one -// specific peer + outboundTag, exactly like routing any other protocol). -// -// An inbound is skipped, individually, when its own tag is already taken by -// another config entry — mirroring injectMtprotoEgress/injectPanelEgress's -// own defensive check, even though a real collision shouldn't be possible -// (inbound tags are unique, and the main GenXrayInboundConfig loop already -// excludes mtproto/amneziawg inbounds from ever claiming their own tag -// there). Generated state is hot-appliable and never modifies the stored -// template or restarts the core. -func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) { +// No RouteThroughXray gate, no qualifying-peer IPv4 check the way +// injectAmneziawgEgress needs one: amneziawg.InstanceFromInbound already +// returns ok=false for zero qualifying peers (Enable && PublicKey != "" && +// len(AllowedIPs) > 0), and peer identity here comes from Email directly, +// not an IPv4 lookup, so a v6-only peer is just as valid an account as any +// other. +func injectAmneziawgnetSocks(cfg *xray.Config, inbounds []*model.Inbound) { existingTags := make(map[string]struct{}, len(cfg.InboundConfigs)) for i := range cfg.InboundConfigs { existingTags[cfg.InboundConfigs[i].Tag] = struct{}{} @@ -735,32 +717,38 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) { continue } inst, ok := amneziawg.InstanceFromInbound(inbound) - if !ok || !inst.RouteThroughXray { - continue - } - hasQualifyingPeer := false - for _, p := range inst.Peers { - if amneziawg.FirstIPv4(p.AllowedIPs) != "" { - hasQualifyingPeer = true - break - } - } - if !hasQualifyingPeer { + if !ok { continue } if _, taken := existingTags[inbound.Tag]; taken { - logger.Warning("amneziawg egress: inbound tag [", inbound.Tag, "] already present in generated config, skipping its bridge") + logger.Warning("amneziawgnet socks: inbound tag [", inbound.Tag, "] already present in generated config, skipping its relay inbound") continue } + + emails := make([]string, 0, len(inst.Peers)) + for _, p := range inst.Peers { + if p.Email != "" { + emails = append(emails, p.Email) + } + } + if len(emails) == 0 { + continue + } + + settings, err := amneziawgnet.SocksInboundSettings(emails, amneziawgnet.SocksPassword()) + if err != nil { + logger.Warning("amneziawgnet socks: building settings for inbound [", inbound.Tag, "]: ", err) + continue + } + existingTags[inbound.Tag] = struct{}{} cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{ - Listen: json_util.RawMessage(`"127.0.0.1"`), - Port: amneziawg.EgressPortForInbound(inbound.Id), - Protocol: "dokodemo-door", - Settings: json_util.RawMessage(amneziawgEgressDokodemoSettings), - StreamSettings: json_util.RawMessage(amneziawgEgressStreamSettings), - Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings), - Tag: inbound.Tag, + Listen: json_util.RawMessage(`"127.0.0.1"`), + Port: amneziawgnet.SOCKSPortForInbound(inbound.Id), + Protocol: "socks", + Settings: json_util.RawMessage(settings), + Sniffing: json_util.RawMessage(amneziawgEgressSniffingSettings), + Tag: inbound.Tag, }) } } diff --git a/internal/web/service/xray_config_inject_test.go b/internal/web/service/xray_config_inject_test.go index 6d930ee24..0fe077c97 100644 --- a/internal/web/service/xray_config_inject_test.go +++ b/internal/web/service/xray_config_inject_test.go @@ -7,6 +7,7 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawgnet" "github.com/mhsanaei/3x-ui/v3/internal/database/model" xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/json_util" @@ -561,46 +562,46 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) { } func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound { - server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: true} + server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24} settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients}) return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)} } -func TestInjectAmneziawgEgress_CreatesBridgeTaggedWithInboundsOwnTag(t *testing.T) { +func TestInjectAmneziawgnetSocks_CreatesRelayTaggedWithInboundsOwnTag(t *testing.T) { cfg := egressTestConfig() before := string(cfg.RouterConfig) inbound := amneziawgInbound(7, "awg-7", []model.Client{ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, }) - injectAmneziawgEgress(cfg, []*model.Inbound{inbound}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound}) if len(cfg.InboundConfigs) != 2 { - t.Fatalf("expected the bridge to be appended, got %d inbounds", len(cfg.InboundConfigs)) + t.Fatalf("expected the relay inbound to be appended, got %d inbounds", len(cfg.InboundConfigs)) } ib := cfg.InboundConfigs[1] - if ib.Tag != "awg-7" || ib.Protocol != "dokodemo-door" || ib.Port != amneziawg.EgressPortForInbound(7) { - t.Fatalf("bridge must reuse the inbound's own tag (so it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib) + if ib.Tag != "awg-7" || ib.Protocol != "socks" || ib.Port != amneziawgnet.SOCKSPortForInbound(7) { + t.Fatalf("relay inbound must reuse the inbound's own tag (so per-inbound stats totals keep matching, and it's already selectable in the stock Routing page) and this instance's own derived port, got %+v", ib) } if string(ib.Listen) != `"127.0.0.1"` { - t.Fatalf("bridge must listen on loopback, got %s", ib.Listen) + t.Fatalf("relay inbound must listen on loopback, got %s", ib.Listen) } - if !strings.Contains(string(ib.StreamSettings), `"tproxy":"tproxy"`) { - t.Fatalf("bridge must set sockopt.tproxy, got %s", ib.StreamSettings) + if !strings.Contains(string(ib.Settings), `"auth":"password"`) || !strings.Contains(string(ib.Settings), `"udp":true`) { + t.Fatalf("relay inbound must require password auth and allow UDP ASSOCIATE, got %s", ib.Settings) } - if !strings.Contains(string(ib.Settings), `"followRedirect":true`) { - t.Fatalf("bridge must set followRedirect, got %s", ib.Settings) + if !strings.Contains(string(ib.Settings), `"a@x"`) { + t.Fatalf("relay inbound must have an account for the peer's email, got %s", ib.Settings) } if !strings.Contains(string(ib.Sniffing), `"enabled":true`) { - t.Fatalf("bridge must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing) + t.Fatalf("relay inbound must enable sniffing -- a peer's own DNS resolution means the decapsulated traffic never carries a domain at the network layer, so domain-based Routing rules can only ever match via sniffing the payload, got %s", ib.Sniffing) } // No auto-generated routing rule: it's entirely up to the admin's own // Routing-page rules, same as any other protocol's inbound tag. if string(cfg.RouterConfig) != before { - t.Fatalf("injectAmneziawgEgress must never touch the routing section, got %s", cfg.RouterConfig) + t.Fatalf("injectAmneziawgnetSocks must never touch the routing section, got %s", cfg.RouterConfig) } } -func TestInjectAmneziawgEgress_MultipleInboundsEachGetOwnBridge(t *testing.T) { +func TestInjectAmneziawgnetSocks_MultipleInboundsEachGetOwnRelay(t *testing.T) { cfg := egressTestConfig() inbound1 := amneziawgInbound(1, "awg-1", []model.Client{ {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, @@ -608,21 +609,21 @@ func TestInjectAmneziawgEgress_MultipleInboundsEachGetOwnBridge(t *testing.T) { inbound2 := amneziawgInbound(2, "awg-2", []model.Client{ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}}, }) - injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2}) if len(cfg.InboundConfigs) != 3 { - t.Fatalf("expected one bridge per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs) + t.Fatalf("expected one relay inbound per inbound (plus the pre-existing one), got %d inbounds: %+v", len(cfg.InboundConfigs), cfg.InboundConfigs) } byTag := map[string]int{} for _, ib := range cfg.InboundConfigs[1:] { byTag[ib.Tag] = ib.Port } - if byTag["awg-1"] != amneziawg.EgressPortForInbound(1) || byTag["awg-2"] != amneziawg.EgressPortForInbound(2) { + if byTag["awg-1"] != amneziawgnet.SOCKSPortForInbound(1) || byTag["awg-2"] != amneziawgnet.SOCKSPortForInbound(2) { t.Fatalf("each inbound must get its own tag and its own derived port, got %+v", byTag) } } -func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) { +func TestInjectAmneziawgnetSocks_NoQualifyingPeerSkipsRelay(t *testing.T) { cases := []struct { name string client model.Client @@ -632,13 +633,14 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) { {"no PublicKey", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}}, true}, {"no AllowedIPs", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a"}, true}, {"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, false}, + {"no Email", model.Client{Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, true}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { cfg := egressTestConfig() inbound := amneziawgInbound(1, "awg-1", []model.Client{c.client}) inbound.Enable = c.enable - injectAmneziawgEgress(cfg, []*model.Inbound{inbound}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound}) if len(cfg.InboundConfigs) != 1 { t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs)) } @@ -646,9 +648,13 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) { } } -func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) { +func TestInjectAmneziawgnetSocks_AlwaysOnRegardlessOfLegacyRouteThroughXrayField(t *testing.T) { + // Unlike the retired kernel-module bridge, the embedded relay has no + // opt-in gate: there is no alternative datapath once traffic is + // decapsulated in gVisor. A stale RouteThroughXray=false left over from + // a pre-cutover install must not suppress the relay inbound. cfg := egressTestConfig() - server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24} // RouteThroughXray left false + server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: false} settings, _ := json.Marshal(amneziawg.InboundSettings{ Server: &server, Clients: []model.Client{ @@ -656,13 +662,13 @@ func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) { }, }) inbound := &model.Inbound{Id: 1, Tag: "awg-1", Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)} - injectAmneziawgEgress(cfg, []*model.Inbound{inbound}) - if len(cfg.InboundConfigs) != 1 { - t.Fatalf("an inbound with RouteThroughXray off must never get a bridge, got %+v", cfg.InboundConfigs) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound}) + if len(cfg.InboundConfigs) != 2 { + t.Fatalf("the relay inbound must always be created regardless of RouteThroughXray, got %+v", cfg.InboundConfigs) } } -func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) { +func TestInjectAmneziawgnetSocks_WrongProtocolOrNodeSkipped(t *testing.T) { cfg := egressTestConfig() vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true} nodeID := 5 @@ -670,13 +676,13 @@ func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) { {Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}}, }) nodeHosted.NodeID = &nodeID - injectAmneziawgEgress(cfg, []*model.Inbound{vless, nodeHosted}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{vless, nodeHosted}) if len(cfg.InboundConfigs) != 1 { - t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a bridge, got %+v", cfg.InboundConfigs) + t.Fatalf("a non-AmneziaWG or node-hosted inbound must never get a relay inbound, got %+v", cfg.InboundConfigs) } } -func TestInjectAmneziawgEgress_TagCollisionSkipsThatInboundOnly(t *testing.T) { +func TestInjectAmneziawgnetSocks_TagCollisionSkipsThatInboundOnly(t *testing.T) { cfg := egressTestConfig() cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: "awg-1"}) @@ -686,20 +692,20 @@ func TestInjectAmneziawgEgress_TagCollisionSkipsThatInboundOnly(t *testing.T) { inbound2 := amneziawgInbound(2, "awg-2", []model.Client{ {Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.9.1.2/32"}}, }) - injectAmneziawgEgress(cfg, []*model.Inbound{inbound1, inbound2}) + injectAmneziawgnetSocks(cfg, []*model.Inbound{inbound1, inbound2}) - // Started with 2 (api + the colliding vless entry); only awg-2's bridge - // should have been added, awg-1's skipped since its tag is taken. + // Started with 2 (api + the colliding vless entry); only awg-2's relay + // inbound should have been added, awg-1's skipped since its tag is taken. if len(cfg.InboundConfigs) != 3 { - t.Fatalf("expected only the non-colliding inbound's bridge to be added, got %+v", cfg.InboundConfigs) + t.Fatalf("expected only the non-colliding inbound's relay inbound to be added, got %+v", cfg.InboundConfigs) } found := false for _, ib := range cfg.InboundConfigs { - if ib.Tag == "awg-2" && ib.Protocol == "dokodemo-door" { + if ib.Tag == "awg-2" && ib.Protocol == "socks" { found = true } } if !found { - t.Fatal("awg-2's bridge must still be created despite awg-1's tag collision") + t.Fatal("awg-2's relay inbound must still be created despite awg-1's tag collision") } }