feat(amneziawg): Phase 2c — RouteViaXray (TPROXY into Xray)

Per-client toggle (RouteThroughXray + RouteOutboundTag) that TPROXYs a
peer's traffic into Xray instead of NAT'ing it straight out the host's
network interface, so it can egress through any configured Xray
outbound (or balancer) — a VLESS/proxy chain, WARP, etc.

Discovered mid-design that internal/mtproto already solved the "let a
native sidecar's traffic egress through Xray" problem once, via
routeThroughXray/routeXrayPort/outboundTag + injectMtprotoEgress: a
loopback bridge inbound plus a routing rule. AmneziaWG can't reuse it
directly — mtg is a userspace process that dials *out* through a local
SOCKS proxy, while AmneziaWG is a kernel tunnel interface with no
process of its own to redirect. The Xray-side shape carries over
almost exactly, the kernel-side plumbing is new:

- internal/amneziawg/route_egress.go: EgressPort/EgressTag/EgressFwmark/
  EgressTable are one shared constant set, not one bridge per peer.
  Every routed peer, across every AmneziaWG instance, TPROXYs into the
  *same* loopback dokodemo-door bridge; the per-peer distinction happens
  downstream, in Xray's own router, matched against each peer's
  TPROXY-preserved source IP (Xray's field-rule `source` matcher — a
  capability the router already had). This avoids two independent
  reconcile loops (the AWG manager and the Xray-config generator) ever
  having to agree on a dynamically-picked port for each peer.
- manager.go's defaultPostUpDown emits a per-peer mangle-table TPROXY
  rule (matched by tunnel source IP) for each opted-in peer, plus the
  fwmark->table->local-everywhere policy route TPROXY needs to deliver
  those packets to the bridge. That policy route is system-wide, not
  interface-specific, so — like the existing IPv6-forwarding sysctl —
  it's added idempotently and never torn down in PostDown; a second
  AmneziaWG instance with its own routed peers must find it already in
  place, not race to remove what the first still needs.
- The existing portForwardFingerprint became hostRulesFingerprint,
  covering both ForwardedPorts and RouteThroughXray/RouteOutboundTag:
  both only ever take effect through PostUp/PostDown, which `awg
  syncconf` never re-runs, so either one changing must force the same
  full interface bounce.
- internal/web/service/xray.go's new injectAmneziawgEgress mirrors
  injectMtprotoEgress/injectPanelEgress's safety rules, adapted for one
  bridge serving many peers: an invalid or missing outbound target
  skips only that one peer's rule (not the whole bridge, since other
  peers may still need it), while the bridge itself is skipped
  entirely when nothing needs it or its tag is already taken by a real
  inbound.

Frontend: a Switch + conditional outbound Select on the client form
(showAmneziawg only), mirroring mtproto's own routeThroughXray UI and
reusing its useOutboundTags hook. install.sh now modprobes the
mainline TPROXY modules (xt_TPROXY, nf_tproxy_ipv4/ipv6) alongside the
existing AmneziaWG setup — ordinary upstream kernel modules, no
DKMS/PPA needed unlike the AmneziaWG module itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 18:34:37 +03:00
parent 69de904bf6
commit d1b77b2aa4
18 changed files with 837 additions and 111 deletions
+131
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"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"
@@ -327,6 +328,15 @@ 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)
// Wire the panel's own HTTP traffic through the configured outbound, after
// the subscription merge so subscription outbound tags are valid targets.
if egressTag, err := s.settingService.GetPanelOutbound(); err != nil {
@@ -621,6 +631,127 @@ 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"}}`
// amneziawgRouteRule is one routed AmneziaWG peer — gathered from every
// enabled AmneziaWG inbound's client list — that injectAmneziawgEgress turns
// into a source-matched routing rule against the shared bridge.
type amneziawgRouteRule struct {
sourceIP string
outboundTag string
}
// injectAmneziawgEgress wires every RouteThroughXray AmneziaWG peer, across
// every enabled AmneziaWG inbound, into the generated config through one
// loopback dokodemo-door bridge shared by all of them (tag
// amneziawg.EgressTag, port amneziawg.EgressPort) rather than one bridge per
// peer: the TPROXY rule that redirects a peer's traffic there is per-peer
// (see internal/amneziawg's defaultPostUpDown), but distinguishing which peer
// a given connection came from — and picking its own outbound — happens
// here, in Xray's own router, matched against the TPROXY-preserved source
// IP. Mirrors injectMtprotoEgress/injectPanelEgress: an invalid or missing
// outbound target skips that one peer's rule, not the whole bridge; the
// bridge itself is skipped entirely when no peer needs it or its tag is
// already taken by a real inbound. Generated state is hot-appliable and
// never modifies the stored template or restarts the core.
func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
var rules []amneziawgRouteRule
for _, inbound := range inbounds {
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
continue
}
var parsed amneziawg.InboundSettings
if err := json.Unmarshal([]byte(inbound.Settings), &parsed); err != nil {
continue
}
for _, c := range parsed.Clients {
if !c.Enable || !c.RouteThroughXray {
continue
}
sourceIP := amneziawg.FirstIPv4(c.AllowedIPs)
if sourceIP == "" {
continue
}
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: c.RouteOutboundTag})
}
}
if len(rules) == 0 {
return
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag == amneziawg.EgressTag {
logger.Warning("amneziawg egress: inbound tag [", amneziawg.EgressTag, "] already present in generated config, skipping bridge")
return
}
}
routing := map[string]any{}
if len(cfg.RouterConfig) > 0 {
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
logger.Warning("amneziawg egress: routing section is unparsable, skipping injection:", err)
return
}
}
existingRules, _ := routing["rules"].([]any)
newRules := make([]any, 0, len(rules))
for _, r := range rules {
if r.outboundTag == "" {
// No chosen outbound: the peer's traffic still lands on the
// bridge (it's already TPROXY'd there at the kernel level) but
// with no rule of its own it falls through to whatever the rest
// of the router decides, matching injectMtprotoEgress's
// no-outbound-selected behavior.
continue
}
if !routingTargetExists(routing, cfg.OutboundConfigs, r.outboundTag) {
logger.Warning("amneziawg egress: target tag [", r.outboundTag, "] not found, skipping rule for [", r.sourceIP, "]")
continue
}
rule := map[string]any{
"type": "field",
"inboundTag": []any{amneziawg.EgressTag},
"source": []any{r.sourceIP + "/32"},
}
if routingTagIsBalancer(routing, r.outboundTag) {
rule["balancerTag"] = r.outboundTag
} else {
rule["outboundTag"] = r.outboundTag
}
newRules = append(newRules, rule)
}
if len(newRules) > 0 {
routing["rules"] = append(newRules, existingRules...)
newRouting, err := json.Marshal(routing)
if err != nil {
logger.Warning("amneziawg egress: failed to rebuild routing section, skipping injection:", err)
return
}
cfg.RouterConfig = json_util.RawMessage(newRouting)
}
cfg.InboundConfigs = append(cfg.InboundConfigs, xray.InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: amneziawg.EgressPort,
Protocol: "dokodemo-door",
Settings: json_util.RawMessage(amneziawgEgressDokodemoSettings),
StreamSettings: json_util.RawMessage(amneziawgEgressStreamSettings),
Tag: amneziawg.EgressTag,
})
}
// mergeSubscriptionOutbounds appends the subscription outbounds to the
// OutboundConfigs array of the xray config. It works on the already-unmarshaled
// template so that manually configured outbounds are never overwritten.
@@ -3,8 +3,10 @@ package service
import (
"encoding/json"
"os"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/amneziawg"
"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"
@@ -557,3 +559,230 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
}
}
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
settings, _ := json.Marshal(amneziawg.InboundSettings{Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
type amneziawgRouting struct {
Rules []struct {
InboundTag []string `json:"inboundTag"`
OutboundTag string `json:"outboundTag"`
BalancerTag string `json:"balancerTag"`
Source []string `json:"source"`
Type string `json:"type"`
} `json:"rules"`
}
func TestInjectAmneziawgEgress_WithOutbound(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("expected the shared bridge to be appended, got %d inbounds", len(cfg.InboundConfigs))
}
ib := cfg.InboundConfigs[1]
if ib.Tag != amneziawg.EgressTag || ib.Protocol != "dokodemo-door" || ib.Port != amneziawg.EgressPort {
t.Fatalf("unexpected bridge inbound: %+v", ib)
}
if string(ib.Listen) != `"127.0.0.1"` {
t.Fatalf("bridge 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), `"followRedirect":true`) {
t.Fatalf("bridge must set followRedirect, got %s", ib.Settings)
}
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 {
t.Fatalf("expected the egress rule prepended to the existing rule, got %+v", routing.Rules)
}
first := routing.Rules[0]
if first.Type != "field" || first.OutboundTag != "warp" ||
len(first.InboundTag) != 1 || first.InboundTag[0] != amneziawg.EgressTag ||
len(first.Source) != 1 || first.Source[0] != "10.8.1.2/32" {
t.Fatalf("egress rule must bind the shared bridge tag + peer source IP to the outbound, got %+v", first)
}
}
func TestInjectAmneziawgEgress_MultiplePeersDifferentOutbounds(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
{Email: "b@x", Enable: true, AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("expected exactly one shared bridge regardless of peer count, got %d inbounds", len(cfg.InboundConfigs))
}
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 3 { // 2 egress rules + the 1 pre-existing rule
t.Fatalf("expected one rule per routed peer, got %+v", routing.Rules)
}
bySource := map[string]string{}
for _, r := range routing.Rules {
if len(r.Source) == 1 {
bySource[r.Source[0]] = r.OutboundTag
}
}
if bySource["10.8.1.2/32"] != "warp" || bySource["10.8.1.3/32"] != "direct" {
t.Fatalf("each peer must route to its own outbound, got %+v", bySource)
}
}
func TestInjectAmneziawgEgress_NoOutboundLeavesRouting(t *testing.T) {
cfg := egressTestConfig()
before := string(cfg.RouterConfig)
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("bridge must still be appended without an outbound, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("no outbound selected means no rule change, got %s", cfg.RouterConfig)
}
}
func TestInjectAmneziawgEgress_BalancerTag(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{"rules":[],"balancers":[{"tag":"lb","selector":["warp"]}]}`)
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "lb"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 1 || routing.Rules[0].BalancerTag != "lb" || routing.Rules[0].OutboundTag != "" {
t.Fatalf("a balancer tag must target balancerTag, got %+v", routing.Rules)
}
}
func TestInjectAmneziawgEgress_Disabled(t *testing.T) {
cases := []struct {
name string
client model.Client
enable bool
}{
{"client disabled", model.Client{Email: "a@x", Enable: false, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
{"RouteThroughXray off", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "warp"}, true},
{"no AllowedIPs", model.Client{Email: "a@x", Enable: true, RouteThroughXray: true, RouteOutboundTag: "warp"}, true},
{"inbound disabled", model.Client{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, false},
}
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})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("%s must be a no-op, got %d inbounds", c.name, len(cfg.InboundConfigs))
}
})
}
}
func TestInjectAmneziawgEgress_TagCollisionSkips(t *testing.T) {
cfg := egressTestConfig()
cfg.InboundConfigs = append(cfg.InboundConfigs,
xray.InboundConfig{Port: 1234, Protocol: "vless", Tag: amneziawg.EgressTag})
before := string(cfg.RouterConfig)
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 || string(cfg.RouterConfig) != before {
t.Fatal("a real inbound already owning the shared bridge tag must make injection a no-op")
}
}
func TestInjectAmneziawgEgress_MissingTargetSkipsOnlyThatPeer(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
{Email: "b@x", Enable: true, AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "removed-subscription-outbound"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("the bridge must still be created for the peer with a valid target, got %+v", cfg.InboundConfigs)
}
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 { // only a@x's rule + the 1 pre-existing rule
t.Fatalf("only the peer with a valid target should get a rule, got %+v", routing.Rules)
}
if routing.Rules[0].Source[0] != "10.8.1.2/32" {
t.Fatalf("expected a@x's rule, got %+v", routing.Rules[0])
}
}
func TestInjectAmneziawgEgress_BadOutboundsSkipsRulesKeepsBridge(t *testing.T) {
cfg := egressTestConfig()
cfg.OutboundConfigs = json_util.RawMessage(`{not json`)
before := string(cfg.RouterConfig)
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("unparsable outbounds must still expose the bridge (other peers may not need routing), got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != before {
t.Fatalf("unparsable outbounds means no target can resolve, so no rule change, got %s", cfg.RouterConfig)
}
}
func TestInjectAmneziawgEgress_BadRoutingSkipsEverything(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = json_util.RawMessage(`{not json`)
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 1 {
t.Fatalf("unparsable routing must not expose the bridge either, got %+v", cfg.InboundConfigs)
}
if string(cfg.RouterConfig) != `{not json` {
t.Fatalf("unparsable routing must be left untouched, got %s", cfg.RouterConfig)
}
}
func TestInjectAmneziawgEgress_NoRoutingSection(t *testing.T) {
cfg := egressTestConfig()
cfg.RouterConfig = nil
inbound := amneziawgInbound(1, "awg-1", []model.Client{
{Email: "a@x", Enable: true, AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(cfg.InboundConfigs) != 2 || len(routing.Rules) != 1 || routing.Rules[0].OutboundTag != "direct" {
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing.Rules)
}
}
+5
View File
@@ -922,6 +922,11 @@
"amneziaWgAllowedIPsHint": "Leave empty to auto-assign; separate entries with commas",
"amneziaWgForwardedPorts": "Forwarded Ports",
"amneziaWgForwardedPortsHint": "Ports/ranges DNAT'd to this client, e.g. 80, 443, 8000-8100. Leave empty for none.",
"amneziaWgRouteThroughXray": "Route via Xray",
"amneziaWgRouteThroughXrayHint": "Send this client's traffic through Xray instead of straight out the server's network interface.",
"amneziaWgRouteOutboundTag": "Outbound",
"amneziaWgRouteOutboundTagHint": "Which Xray outbound (or balancer) this client's traffic exits through. Leave empty to use Xray's default routing.",
"amneziaWgRouteOutboundTagPlaceholder": "Select an outbound",
"amneziaWgConfig": "AmneziaWG config",
"mtprotoSecret": "MTProto secret",
"mtprotoSecretHint": "The client's FakeTLS secret. Regenerate to rotate it.",
+5
View File
@@ -922,6 +922,11 @@
"amneziaWgAllowedIPsHint": "Оставьте пустым для автоназначения; разделяйте записи запятыми",
"amneziaWgForwardedPorts": "Проброс портов",
"amneziaWgForwardedPortsHint": "Порты/диапазоны, DNAT'ящиеся на этого клиента, например 80, 443, 8000-8100. Оставьте пустым, если не нужно.",
"amneziaWgRouteThroughXray": "Маршрутизировать через Xray",
"amneziaWgRouteThroughXrayHint": "Направлять трафик этого клиента через Xray вместо прямого выхода через сетевой интерфейс сервера.",
"amneziaWgRouteOutboundTag": "Исходящий",
"amneziaWgRouteOutboundTagHint": "Через какой исходящий (outbound) или балансировщик Xray выходит трафик этого клиента. Оставьте пустым, чтобы использовать маршрутизацию Xray по умолчанию.",
"amneziaWgRouteOutboundTagPlaceholder": "Выберите исходящий",
"amneziaWgConfig": "Конфиг AmneziaWG",
"mtprotoSecret": "Секрет MTProto",
"mtprotoSecretHint": "Секрет FakeTLS клиента. Перегенерируйте, чтобы сменить.",