fix(xray): hot-apply Hysteria client changes without replacing the inbound (#6606)

* fix(xray): hot-apply Hysteria client changes without replacing the inbound

diffInboundUsers only allowed per-user AlterInbound ops for vless, vmess
and trojan. For a hysteria inbound every client add/remove/update became
DelInbound + AddInbound: the UDP listener was recreated and all QUIC
sessions of that inbound were lost. quic-go sends no stateless reset, so
every connected client stalled until its idle timeout (30s by default)
after each unrelated client mutation.

XrayAPI.AddUser already builds a hysteria account and Xray-core's
hysteria server implements AddUser/RemoveUser, so adding the protocol to
userDiffableProtocols is sufficient.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(xray): say which branch each drop-guard protocol takes

With hysteria in userDiffableProtocols its dropped client reaches the
guard through the per-user diff, so the test named for protocols the
diff cannot handle no longer described its hysteria case.

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
SERGE BLCHV
2026-09-27 01:03:43 +05:00
committed by GitHub
parent 6ab718f813
commit 6ac0c88084
3 changed files with 29 additions and 4 deletions
+1 -1
View File
@@ -221,7 +221,7 @@ func droppedClients(oldIb, newIb *InboundConfig) []UserOp {
return dropped
}
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}}
var userDiffableProtocols = map[string]struct{}{"vless": {}, "vmess": {}, "trojan": {}, "hysteria": {}}
// diffInboundUsers emits per-user AlterInbound ops when two same-tag inbounds
// differ only in settings.clients, so the handler (and its listener) survives.
+3 -3
View File
@@ -16,9 +16,9 @@ func hotConfigWithClients(clients string) *Config {
return cfg
}
// diffInboundUsers refuses shadowsocks and hysteria, so their dropped clients
// reach the guard through the inbound instead of through a per-user op.
func TestHotDiffDropsUsersOnProtocolsItCannotDiff(t *testing.T) {
// Shadowsocks reaches the drop guard through the inbound's settings.clients compare,
// hysteria through its per-user diff; either way a dropped client must be reported.
func TestHotDiffDropsUsersOnShadowsocksAndHysteria(t *testing.T) {
for _, protocol := range []string{"shadowsocks", "hysteria"} {
t.Run(protocol, func(t *testing.T) {
withClients := func(clients string) *Config {
+25
View File
@@ -511,3 +511,28 @@ func TestComputeHotDiff_NoauthSocksBridgeStaysHot(t *testing.T) {
t.Fatalf("expected a plain remove+add for the changed bridge, got %+v", diff)
}
}
// Replacing a hysteria handler closes the UDP listener its QUIC sessions share,
// and clients get no reset: they stall until their own idle timeout expires.
func TestComputeHotDiff_HysteriaClientOnlyChangeKeepsListener(t *testing.T) {
stream := json_util.RawMessage(`{"network":"hysteria","security":"tls","hysteriaSettings":{"version":2}}`)
oldCfg := makeHotConfig()
oldCfg.InboundConfigs[1].Protocol = "hysteria"
oldCfg.InboundConfigs[1].StreamSettings = stream
oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"version":2,"clients":[{"email":"a","auth":"auth-a"}]}`)
newCfg := makeHotConfig()
newCfg.InboundConfigs[1].Protocol = "hysteria"
newCfg.InboundConfigs[1].StreamSettings = stream
newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"version":2,"clients":[{"email":"a","auth":"auth-a"},{"email":"b","auth":"auth-b"}]}`)
diff, ok := ComputeHotDiff(oldCfg, newCfg)
if !ok {
t.Fatal("client-only change must be hot-appliable")
}
if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
t.Fatalf("hysteria client-only change must not replace the handler, got removed=%v added=%d", diff.RemovedInboundTags, len(diff.AddedInbounds))
}
if len(diff.RemovedUsers) != 0 || len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" || diff.AddedUsers[0].Protocol != "hysteria" {
t.Fatalf("expected a single AddUser op for b, got %+v", diff)
}
}