feat(amneziawg): make the Xray TPROXY bridge a per-inbound opt-in

Addresses Finding 10 from the automated PR review: an always-on TPROXY
bridge makes every AmneziaWG tunnel hard-depend on Xray being up (all
traffic, including DNS, drops whenever Xray restarts), and forces a full
awg-quick down+up bounce on any client add/remove/re-IP, permanently
losing the syncconf fast path.

Adds ServerSettings.RouteThroughXray (off by default):

- defaultPostUpDown only emits the TPROXY/policy-route rules when it's
  on; a plain AmneziaWG tunnel now has zero Xray dependency out of the
  box.
- structuralFingerprint covers it (toggling it changes whether PostUp/
  PostDown contain any TPROXY rules at all -- structural, not a
  per-peer host-rule). hostRulesFingerprint's IPv4 tracking is now
  itself conditional on RouteThroughXray (and IPv6 tracking on
  IPv6Enabled), so an instance that never uses either keeps the
  syncconf fast path for a plain peer re-IP.
- injectAmneziawgEgress only creates a bridge for inbounds that opted
  in; checkAmneziawgEgressConflict (the Finding-7 fix) now parses each
  candidate through InstanceFromInbound so a non-routed inbound's port
  is correctly never treated as reserved.
- New inbound-level Switch in the AmneziaWG form; the actual outbound
  decision is still made entirely through the panel's stock Routing
  page, same as before -- only whether the bridge exists at all is now
  a choice.

Translation keys added to all 13 locales in the same commit this time,
not backfilled later (see Finding 9's lesson).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-26 00:39:32 +03:00
parent c41f97cf86
commit 71dc453970
28 changed files with 257 additions and 85 deletions
+10 -3
View File
@@ -231,9 +231,12 @@ func (s *InboundService) checkPortConflict(inbound *model.Inbound, ignoreId int)
// checkAmneziawgEgressConflict reports whether inbound's own port collides
// with an existing, enabled local AmneziaWG inbound's automatic Xray bridge
// port. 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.
// 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) {
db := database.GetDB()
var candidates []*model.Inbound
@@ -245,6 +248,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 {
continue
}
if amneziawg.EgressPortForInbound(c.Id) != inbound.Port {
continue
}
+34 -2
View File
@@ -731,13 +731,19 @@ 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.
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) {
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)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
@@ -769,7 +775,7 @@ func TestCheckPortConflict_AmneziawgEgressBridgeBlockedLocal(t *testing.T) {
// 127.0.0.1 on the local panel's own Xray.
func TestCheckPortConflict_AmneziawgEgressBridgeAllowedOnNode(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)
var awgInbound model.Inbound
if err := database.GetDB().Where("tag = ?", "awg-1").First(&awgInbound).Error; err != nil {
@@ -813,6 +819,32 @@ 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) {
setupConflictDB(t)
seedInboundConflict(t, "awg-1", "0.0.0.0", 51820, model.AmneziaWG, ``, `{}`)
var awgInbound model.Inbound
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)
svc := &InboundService{}
candidate := &model.Inbound{
Tag: "vless-bridge",
Listen: "0.0.0.0",
Port: bridgePort,
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)
}
}
// An unrelated port never conflicts with the bridge.
func TestCheckPortConflict_AmneziawgEgressBridgeDifferentPortAllowed(t *testing.T) {
setupConflictDB(t)
+13 -10
View File
@@ -645,18 +645,21 @@ const amneziawgEgressDokodemoSettings = `{"allowedNetwork":"tcp,udp","followRedi
// socket.
const amneziawgEgressStreamSettings = `{"sockopt":{"tproxy":"tproxy"}}`
// 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,
// 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.
//
// 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
// 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
@@ -680,7 +683,7 @@ func injectAmneziawgEgress(cfg *xray.Config, inbounds []*model.Inbound) {
continue
}
inst, ok := amneziawg.InstanceFromInbound(inbound)
if !ok {
if !ok || !inst.RouteThroughXray {
continue
}
hasQualifyingPeer := false
@@ -561,7 +561,7 @@ 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}
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24, RouteThroughXray: true}
settings, _ := json.Marshal(amneziawg.InboundSettings{Server: &server, Clients: clients})
return &model.Inbound{Id: id, Tag: tag, Protocol: model.AmneziaWG, Enable: true, Settings: string(settings)}
}
@@ -643,6 +643,22 @@ func TestInjectAmneziawgEgress_NoQualifyingPeerSkipsBridge(t *testing.T) {
}
}
func TestInjectAmneziawgEgress_RouteThroughXrayOffSkipsBridge(t *testing.T) {
cfg := egressTestConfig()
server := amneziawg.ServerSettings{SubnetIP: "10.8.1.0", SubnetCIDR: 24} // RouteThroughXray left false
settings, _ := json.Marshal(amneziawg.InboundSettings{
Server: &server,
Clients: []model.Client{
{Email: "a@x", Enable: true, PublicKey: "pub-a", AllowedIPs: []string{"10.8.1.2/32"}},
},
})
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)
}
}
func TestInjectAmneziawgEgress_WrongProtocolOrNodeSkipped(t *testing.T) {
cfg := egressTestConfig()
vless := &model.Inbound{Id: 1, Tag: "in-1", Protocol: model.VLESS, Enable: true}