fix(amneziawg): make RouteViaXray an inbound-level option too

RouteThroughXray/RouteOutboundTag were client-only, but the more common
case is "route this whole AmneziaWG server's traffic through Xray", not
configuring every peer individually. Add the same pair to ServerSettings
(inbound-level) while keeping the per-client fields as an override —
matching how ExternalInterface/IPv6Enabled already work at the server
level next to per-client settings like ForwardedPorts.

Effective per-peer decision (computed once, in InstanceFromInbound, not
duplicated at each consumer):
  - routed = client.RouteThroughXray || server.RouteThroughXray
  - outbound tag = client's own if set, else the server's default

This means a peer can be routed by the inbound-wide default with no
config of its own, opt in on its own even when the default is off, or
keep the default's on/off but pick a different outbound than everyone
else.

internal/web/service/xray.go's injectAmneziawgEgress now calls
amneziawg.InstanceFromInbound instead of re-parsing InboundSettings and
reading model.Client fields directly — the same effective-routing
computation the kernel-side TPROXY rules use, so the two independent
reconcile loops (Xray-config generation and the AWG manager) can never
quietly disagree about which peers are actually routed.

Frontend: Switch + conditional outbound Select added to the AWG inbound
form (mirroring the client-form version and mtproto's own UI), plus the
inbound-defaults.ts default-object fix that's bitten this project's CI
before (Phase 2a) whenever ServerSettings gains a new required-shaped
field.

Test fixtures in xray_config_inject_test.go needed a real Server block
and PublicKey once injectAmneziawgEgress started requiring a usable
InstanceFromInbound result — both were implicit fixture gaps, not
behavior the old tests were actually asserting.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-25 19:01:31 +03:00
parent d1b77b2aa4
commit 909feefd1d
15 changed files with 278 additions and 38 deletions
+14 -8
View File
@@ -653,7 +653,7 @@ type amneziawgRouteRule struct {
outboundTag string
}
// injectAmneziawgEgress wires every RouteThroughXray AmneziaWG peer, across
// 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
@@ -661,7 +661,13 @@ type amneziawgRouteRule struct {
// (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
// 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.
//
// 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
@@ -672,19 +678,19 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
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 {
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
continue
}
for _, c := range parsed.Clients {
if !c.Enable || !c.RouteThroughXray {
for _, p := range inst.Peers {
if !p.RouteThroughXray {
continue
}
sourceIP := amneziawg.FirstIPv4(c.AllowedIPs)
sourceIP := amneziawg.FirstIPv4(p.AllowedIPs)
if sourceIP == "" {
continue
}
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: c.RouteOutboundTag})
rules = append(rules, amneziawgRouteRule{sourceIP: sourceIP, outboundTag: p.RouteOutboundTag})
}
}
if len(rules) == 0 {
+88 -13
View File
@@ -561,7 +561,23 @@ func TestInjectMtprotoEgress_BadRoutingSkips(t *testing.T) {
}
func amneziawgInbound(id int, tag string, clients []model.Client) *model.Inbound {
settings, _ := json.Marshal(amneziawg.InboundSettings{Clients: clients})
return amneziawgInboundWithServer(id, tag, amneziawg.ServerSettings{}, clients)
}
// amneziawgInboundWithServer lets a test set inbound-wide fields (namely
// RouteThroughXray/RouteOutboundTag) that InstanceFromInbound now needs —
// injectAmneziawgEgress computes each peer's EFFECTIVE routing decision via
// InstanceFromInbound rather than reading model.Client fields raw, so a
// server block (even a minimal valid one) is required for any of these
// tests to see peers at all.
func amneziawgInboundWithServer(id int, tag string, server amneziawg.ServerSettings, clients []model.Client) *model.Inbound {
if server.SubnetIP == "" {
server.SubnetIP = "10.8.1.0"
}
if server.SubnetCIDR == 0 {
server.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)}
}
@@ -578,7 +594,7 @@ type amneziawgRouting struct {
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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -617,8 +633,8 @@ func TestInjectAmneziawgEgress_WithOutbound(t *testing.T) {
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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -647,7 +663,7 @@ 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},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -663,7 +679,7 @@ 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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "lb"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -685,7 +701,7 @@ func TestInjectAmneziawgEgress_Disabled(t *testing.T) {
{"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},
{"inbound disabled", model.Client{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"}, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -706,7 +722,7 @@ func TestInjectAmneziawgEgress_TagCollisionSkips(t *testing.T) {
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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", 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 {
@@ -717,8 +733,8 @@ func TestInjectAmneziawgEgress_TagCollisionSkips(t *testing.T) {
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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}, RouteThroughXray: true, RouteOutboundTag: "removed-subscription-outbound"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -742,7 +758,7 @@ func TestInjectAmneziawgEgress_BadOutboundsSkipsRulesKeepsBridge(t *testing.T) {
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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -758,7 +774,7 @@ 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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "warp"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -774,7 +790,7 @@ 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"},
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
@@ -786,3 +802,62 @@ func TestInjectAmneziawgEgress_NoRoutingSection(t *testing.T) {
t.Fatalf("a routing section must be created with the egress rule, got %+v", routing.Rules)
}
}
func TestInjectAmneziawgEgress_ServerLevelRoutesPeerWithNoOwnFlag(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInboundWithServer(1, "awg-1",
amneziawg.ServerSettings{RouteThroughXray: true, RouteOutboundTag: "warp"},
[]model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
if len(cfg.InboundConfigs) != 2 {
t.Fatalf("the inbound-wide default must route a peer with no client-level flag of its own, got %+v", cfg.InboundConfigs)
}
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "warp" || routing.Rules[0].Source[0] != "10.8.1.2/32" {
t.Fatalf("expected a rule routing the peer to the inbound's default outbound, got %+v", routing.Rules)
}
}
func TestInjectAmneziawgEgress_ClientTagOverridesServerTag(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInboundWithServer(1, "awg-1",
amneziawg.ServerSettings{RouteThroughXray: true, RouteOutboundTag: "warp"},
[]model.Client{
// Routed by the server-wide default, but picks its own outbound.
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteOutboundTag: "direct"},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 || routing.Rules[0].OutboundTag != "direct" {
t.Fatalf("a client's own outbound tag must win over the inbound's default, got %+v", routing.Rules)
}
}
func TestInjectAmneziawgEgress_ServerLevelOffClientCanStillOptIn(t *testing.T) {
cfg := egressTestConfig()
inbound := amneziawgInboundWithServer(1, "awg-1",
amneziawg.ServerSettings{}, // server-wide routing off
[]model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}, RouteThroughXray: true, RouteOutboundTag: "direct"},
{Email: "b@x", Enable: true, PublicKey: "pub-b", AllowedIPs: []string{"10.8.1.3/32"}},
})
injectAmneziawgEgress(cfg, []*model.Inbound{inbound})
var routing amneziawgRouting
if err := json.Unmarshal(cfg.RouterConfig, &routing); err != nil {
t.Fatal(err)
}
if len(routing.Rules) != 2 || routing.Rules[0].Source[0] != "10.8.1.2/32" {
t.Fatalf("a@x must be routed on its own opt-in even with the inbound default off, got %+v", routing.Rules)
}
}
+5
View File
@@ -1945,6 +1945,11 @@
"ipv6SubnetHint": "e.g. fd86:ea04:1115::/64. Required when IPv6 is enabled.",
"ipv6ExternalInterface": "IPv6 External Interface",
"ipv6ExternalInterfaceHint": "Host NIC for the NDP proxy entries. Leave empty to reuse External Interface.",
"routeThroughXray": "Route via Xray (all clients)",
"routeThroughXrayHint": "Send every client's traffic through Xray by default instead of straight out the server's network interface. A client can still enable this on its own even when it's off here.",
"routeOutboundTag": "Default Outbound",
"routeOutboundTagHint": "Default Xray outbound (or balancer) for clients routed through Xray. A client's own outbound choice overrides this. Leave empty to use Xray's default routing.",
"routeOutboundTagPlaceholder": "Select an outbound",
"obfuscation": "Obfuscation parameters",
"regenerateObfuscation": "Regenerate",
"jc": "Jc (junk packet count)",
+5
View File
@@ -1828,6 +1828,11 @@
"ipv6SubnetHint": "Например, fd86:ea04:1115::/64. Обязательно при включённом IPv6.",
"ipv6ExternalInterface": "Внешний интерфейс для IPv6",
"ipv6ExternalInterfaceHint": "Сетевой интерфейс хоста для записей NDP-прокси. Оставьте пустым, чтобы использовать «Внешний интерфейс».",
"routeThroughXray": "Маршрутизировать через Xray (все клиенты)",
"routeThroughXrayHint": "Направлять трафик всех клиентов через Xray по умолчанию вместо прямого выхода через сетевой интерфейс сервера. Клиент может включить это индивидуально, даже если здесь выключено.",
"routeOutboundTag": "Исходящий по умолчанию",
"routeOutboundTagHint": "Исходящий (outbound) или балансировщик Xray по умолчанию для клиентов, маршрутизируемых через Xray. Собственный выбор клиента имеет приоритет. Оставьте пустым для маршрутизации Xray по умолчанию.",
"routeOutboundTagPlaceholder": "Выберите исходящий",
"obfuscation": "Параметры обфускации",
"regenerateObfuscation": "Сгенерировать заново",
"jc": "Jc (кол-во мусорных пакетов)",