feat(amneziawg): replace the TPROXY bridge with a SOCKS5 relay inbound (Phase 3 start)

Hard cutover, part 1: injectAmneziawgnetSocks replaces injectAmneziawgEgress
as the AmneziaWG-side Xray config injector. Every enabled AmneziaWG inbound
now gets an always-on loopback SOCKS5 inbound (built by
amneziawgnet.SocksInboundSettings) instead of an opt-in dokodemo-door TPROXY
bridge -- there's no RouteThroughXray gate anymore since the embedded path
has no alternative datapath once traffic is decapsulated in gVisor. Reuses
the real inbound's own tag, same as before, so per-inbound stats totals
keep matching.

internal/amneziawgnet gains SOCKSPortForInbound (deterministic port
derivation, its own range distinct from the kernel-module bridge's) and
SocksPassword (a process-wide, lazily-generated, not-persisted password --
this traffic never leaves loopback).

port_conflict.go's port-reservation check is updated to match: the new
SOCKS5 relay port is reserved unconditionally for every qualifying
AmneziaWG inbound, not gated on RouteThroughXray.

Not yet done (tracked in the migration plan): swapping the actual manager
call sites (cron job, immediate-apply CRUD, shutdown) from the kernel-module
Manager to amneziawgnet's, and deleting the now-dead TPROXY/awg-quick code.
This commit could not be locally verified beyond internal/amneziawgnet
itself (this machine has no C compiler, so internal/database and anything
that imports it -- including internal/web/service -- can't be built or
vetted here); pushing for real CI feedback before continuing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-08-02 14:33:35 +03:00
parent d163e6ac2d
commit 3450d872d9
5 changed files with 249 additions and 167 deletions
+55
View File
@@ -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
}
+21 -20
View File
@@ -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{
+67 -35
View File
@@ -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)
}
}
+66 -78
View File
@@ -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,
})
}
}
+40 -34
View File
@@ -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")
}
}