refactor(amneziawg): route via Xray through the stock Routing page, not custom toggles

Simplifies RouteViaXray after realizing the panel already has everything
needed: the Routing page already lets an admin pick a source inbound tag
and a target outbound (plus, if they want it, a specific source IP) for
any protocol. Bolting a parallel routeThroughXray/routeOutboundTag pair
onto both the client and inbound forms duplicated that mechanism instead
of using it.

Removed entirely: Client/ClientRecord/ServerSettings/Peer's
RouteThroughXray + RouteOutboundTag fields, the effective-routing OR/
fallback logic in InstanceFromInbound, and the Switch+Select UI on both
forms. Nothing configures "route via Xray" as a setting anymore.

In its place, every enabled AmneziaWG inbound now gets its own Xray
TPROXY bridge unconditionally, by default, no toggle:

- internal/amneziawg: every peer's traffic is always TPROXY'd into that
  instance's own bridge (defaultPostUpDown, port derived from the
  inbound's id via EgressPortForInbound so the kernel side and the
  Xray-config side never need to negotiate a runtime value). Since the
  TPROXY rule is now tied to a peer's mere presence rather than an
  opt-in flag, hostRulesFingerprint now covers every peer unconditionally
  (add/remove/re-IP forces a restart, the same way ForwardedPorts always
  did) instead of skipping peers with nothing to opt into.
- internal/web/service/xray.go's injectAmneziawgEgress creates one
  dokodemo-door bridge per qualifying inbound, tagged with that inbound's
  own real tag — the same trick injectMtprotoEgress already uses (reusing
  a real inbound's tag), which is why it's already selectable in the
  panel's Routing page: InboundService.GetInboundTags() is a plain,
  protocol-blind SELECT over every inbound row's tag, no dedicated UI
  plumbing needed. The function never generates a routing rule itself
  anymore — where (if anywhere) that traffic goes is entirely up to
  whatever rules the admin adds through the existing Routing UI.

Frontend: no new UI at all. Tests rewritten to match — one bridge per
inbound with its own tag/port, no rule generation, no opt-in gating.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 22:37:08 +03:00
parent 909feefd1d
commit db8253421a
19 changed files with 289 additions and 782 deletions
+44 -93
View File
@@ -645,35 +645,36 @@ const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedi
// 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 effectively-routed 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. Reuses amneziawg.InstanceFromInbound for the peer list (rather than
// re-parsing Settings itself) specifically so "is this peer routed, and to
// which outbound" is computed in exactly one place — the same place the
// kernel-side TPROXY rules read it from — and can never quietly diverge
// between the two independent reconcile loops.
// injectAmneziawgEgress gives every enabled 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.
//
// 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.
// Every peer's traffic always lands on the bridge — internal/amneziawg's
// defaultPostUpDown TPROXYs it there unconditionally, there is no per-peer
// or per-inbound opt-in flag — 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) {
var rules []amneziawgRouteRule
existingTags := make(map[string]struct{}, len(cfg.InboundConfigs))
for i := range cfg.InboundConfigs {
existingTags[cfg.InboundConfigs[i].Tag] = struct{}{}
}
for _, inbound := range inbounds {
if inbound.Protocol != model.AmneziaWG || !inbound.Enable || inbound.NodeID != nil {
continue
@@ -682,80 +683,30 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
if !ok {
continue
}
hasQualifyingPeer := false
for _, p := range inst.Peers {
if !p.RouteThroughXray {
continue
if amneziawg.FirstIPv4(p.AllowedIPs) != "" {
hasQualifyingPeer = true
break
}
sourceIP := amneziawg.FirstIPv4(p.AllowedIPs)
if sourceIP == "" {
continue
}
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: p.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.
if !hasQualifyingPeer {
continue
}
if !routingTargetExists(routing, cfg.OutboundConfigs, r.outboundTag) {
logger.Warning("amneziawg egress: target tag [", r.outboundTag, "] not found, skipping rule for [", r.sourceIP, "]")
if _, taken := existingTags[inbound.Tag]; taken {
logger.Warning("amneziawg egress: inbound tag [", inbound.Tag, "] already present in generated config, skipping its bridge")
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)
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),
Tag: inbound.Tag,
})
}
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