mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-04 04:05:04 +03:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user