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.