Bladeren bron

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 <[email protected]>

* 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 <[email protected]>
Co-authored-by: MHSanaei <[email protected]>
SERGE BLCHV 7 uur geleden
bovenliggende
commit
6ac0c88084
3 gewijzigde bestanden met toevoegingen van 29 en 4 verwijderingen
  1. 1 1
      internal/xray/hot_diff.go
  2. 3 3
      internal/xray/hot_diff_drops_users_test.go
  3. 25 0
      internal/xray/hot_diff_test.go

+ 1 - 1
internal/xray/hot_diff.go

@@ -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
internal/xray/hot_diff_drops_users_test.go

@@ -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 - 0
internal/xray/hot_diff_test.go

@@ -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)
+	}
+}