fix(tuic): preserve client identity for Xray user routing (#6875)

TUIC traffic reached Xray through a loopback SOCKS relay that took no login,
so Xray never learned which client a connection belonged to and user routing
rules never matched TUIC (#6874). The relay now carries one account per client
email, so per-user rules match on TCP and both UDP modes; admission stays with
the native TUIC server.

Adding, removing or renaming a TUIC client swaps only that inbound's relay
instead of restarting Xray: new sessions wait behind a bounded gate while live
streams and UDP associations continue, accounts are verified after the swap and
rolled back on failure, one failing relay no longer strands the others or blocks
unrelated live changes, and emails over SOCKS5's 255-byte limit are excluded
with a warning.
This commit is contained in:
Egor
2026-10-24 09:20:39 +05:00
committed by GitHub
parent 07910bf6c0
commit 207c705300
38 changed files with 3510 additions and 170 deletions
+23 -10
View File
@@ -14,6 +14,7 @@ import (
// process. It only covers the sections Xray can reload at runtime: inbounds,
// outbounds and routing rules/balancers.
type HotDiff struct {
TuicRelayChanges []InboundReplacement
RemovedInboundTags []string
AddedInbounds [][]byte
RemovedUsers []UserOp
@@ -26,6 +27,12 @@ type HotDiff struct {
RoutingConfig []byte // full new routing section; nil when unchanged
}
// InboundReplacement is verified independently of ordinary hot changes.
type InboundReplacement struct {
Previous *InboundConfig
Next *InboundConfig
}
// UserOp is a per-user AlterInbound operation; User is nil for removals.
type UserOp struct {
Tag string
@@ -57,7 +64,7 @@ func (d *HotDiff) DropsUsers() bool {
// Empty reports whether the diff contains no operations.
func (d *HotDiff) Empty() bool {
return len(d.RemovedInboundTags) == 0 &&
return len(d.TuicRelayChanges) == 0 && len(d.RemovedInboundTags) == 0 &&
len(d.AddedInbounds) == 0 &&
len(d.RemovedUsers) == 0 &&
len(d.AddedUsers) == 0 &&
@@ -146,11 +153,15 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
if oldIb.Tag == apiTag || oldIb.Tag == "api" {
return false
}
if oldIb.TuicRelay && (!exists || newIb.TuicRelay) {
diff.TuicRelayChanges = append(diff.TuicRelayChanges, InboundReplacement{Previous: oldIb, Next: newIb})
continue
}
if exists && (inboundHasReverseClient(oldIb) || inboundHasReverseClient(newIb)) {
logger.Debug("hot diff: inbound [", oldIb.Tag, "] carries a reverse-tagged client, forcing a full restart instead of a hot swap")
return false
}
if exists {
if exists && !oldIb.TuicRelay {
diff.DroppedClients = append(diff.DroppedClients, droppedClients(oldIb, newIb)...)
}
if exists && diffInboundUsers(oldIb, newIb, diff) {
@@ -165,7 +176,7 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
return false
}
if exists && (inboundUsesSocksAccounts(oldIb) || inboundUsesSocksAccounts(newIb)) {
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
logger.Debug("hot diff: inbound [", oldIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. TUIC or AmneziaWG relay); a gRPC remove+add reports success but was observed in production to silently drop an account, forcing a full restart instead of a hot swap")
return false
}
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
@@ -185,12 +196,16 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
if newIb.Tag == apiTag || newIb.Tag == "api" {
return false
}
if newIb.TuicRelay {
diff.TuicRelayChanges = append(diff.TuicRelayChanges, InboundReplacement{Next: newIb})
continue
}
if inboundUsesTproxy(newIb) {
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a TPROXY target (e.g. internal/amneziawg's Xray egress bridge); a gRPC add reports success but does not reliably bind a working listener, forcing a full restart instead of a hot add")
return false
}
if inboundUsesSocksAccounts(newIb) {
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. internal/amneziawgnet's per-peer relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
logger.Debug("hot diff: new inbound [", newIb.Tag, "] is a password-auth SOCKS5 inbound (e.g. TUIC or AmneziaWG relay); forcing a full restart instead of a hot add, same reasoning as the existing-inbound case above")
return false
}
raw, err := json.Marshal(newIb)
@@ -384,12 +399,10 @@ func inboundUsesTproxy(ib *InboundConfig) bool {
}
// inboundUsesSocksAccounts reports whether an inbound is a password-auth
// SOCKS5 inbound with one or more named accounts -- the shape
// internal/amneziawgnet's per-inbound relay (internal/web/service/xray.go's
// injectAmneziawgnetSocks) is the only generator of in this fork; every
// other SOCKS5 bridge this fork builds (panel egress, per-node egress,
// mtproto egress) uses "noauth" with no per-account identity at all, so this
// check can't accidentally rope in one of those lower-churn bridges.
// SOCKS5 inbound. TUIC and AmneziaWG generate this shape through
// injectTuicRelay and injectAmneziawgnetSocks in internal/web/service/xray.go.
// The panel, per-node and MTProto egress bridges use "noauth" without
// per-account identity, so this check does not affect those bridges.
//
// Real production incident, not a theoretical concern: a single client
// edit under an AmneziaWG inbound left this inbound's settings unchanged in