fix(xray): force a full restart for TPROXY inbounds, never hot-add them

Real incident: an AmneziaWG inbound with RouteThroughXray enabled lost
all internet on that connection after a migration. Root-caused on the
live box -- iptables TPROXY counters were incrementing (packets
correctly redirected to 127.0.0.1:63110), but nothing was actually
listening there (ss showed nothing on that port) until a full
`systemctl restart x-ui`, after which the bridge came up immediately.

Xray-core's gRPC AddInbound reports success for a new sockopt.tproxy
inbound (internal/amneziawg's own Xray egress bridge is the only kind
this fork ever generates) but doesn't reliably bind a working listener
for it outside of process startup -- the bridge silently never comes
up, and RouteThroughXray traffic goes nowhere until the next full
restart happens to occur for an unrelated reason.

diffInbounds already has this exact defensive pattern for REALITY
inbounds ("a gRPC remove+add does not reliably rebuild the REALITY
authenticator"), just never extended to TPROXY, and only in the
already-existing-then-changed branch -- the "brand new inbound" branch
had no such guard at all, which is exactly the path a freshly-enabled
RouteThroughXray bridge takes. Added inboundUsesTproxy and wired it
into both branches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Kuzz007
2026-07-27 15:26:10 +03:00
parent 037ca7330f
commit bdee0a2068
2 changed files with 83 additions and 0 deletions
+51
View File
@@ -385,3 +385,54 @@ func TestComputeHotDiff_RealityClientOnlyChangeStaysHot(t *testing.T) {
t.Fatalf("expected user b added via AlterInbound, got %+v", diff.AddedUsers)
}
}
// TestComputeHotDiff_NewTproxyInboundNeedsRestart reproduces a real incident:
// enabling RouteThroughXray on an AmneziaWG inbound while Xray is already
// running adds a brand-new dokodemo-door bridge with sockopt.tproxy set.
// Xray-core's gRPC AddInbound reports success for this but never actually
// binds a working listener, so TPROXY-redirected peer traffic silently goes
// nowhere until the next full restart -- confirmed directly on a real box
// (iptables TPROXY counters incrementing, but `ss` showing nothing listening
// on the bridge port; the listener only appeared after `systemctl restart
// x-ui`). This must force a restart instead of a hot add.
func TestComputeHotDiff_NewTproxyInboundNeedsRestart(t *testing.T) {
oldCfg := makeHotConfig()
newCfg := makeHotConfig()
newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: 63110,
Protocol: "dokodemo-door",
Tag: "in-443-udp",
Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
})
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("adding a new TPROXY-sockopt inbound must force a full restart, not a gRPC hot add")
}
}
// TestComputeHotDiff_TproxyStreamChangeNeedsRestart mirrors the REALITY
// stream-change test above: an existing TPROXY bridge whose port changed
// (e.g. the AmneziaWG inbound's own id-derived egress port shifted) must not
// be hot-swapped either, for the same reliability reason.
func TestComputeHotDiff_TproxyStreamChangeNeedsRestart(t *testing.T) {
tproxyIb := InboundConfig{
Listen: json_util.RawMessage(`"127.0.0.1"`),
Port: 63110,
Protocol: "dokodemo-door",
Tag: "in-443-udp",
Settings: json_util.RawMessage(`{"allowedNetwork":"tcp,udp","followRedirect":true}`),
StreamSettings: json_util.RawMessage(`{"sockopt":{"tproxy":"tproxy"}}`),
}
oldCfg := makeHotConfig()
oldCfg.InboundConfigs = append(oldCfg.InboundConfigs, tproxyIb)
newCfg := makeHotConfig()
changedIb := tproxyIb
changedIb.Port = 63111
newCfg.InboundConfigs = append(newCfg.InboundConfigs, changedIb)
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
t.Fatal("a TPROXY bridge's port change must force a full restart, not a gRPC hot swap")
}
}