Files
3x-ui/internal/xray/hot_diff_drops_users_test.go
SERGE BLCHV 6ac0c88084 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>
2026-09-26 22:03:43 +02:00

114 lines
3.5 KiB
Go

package xray
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
)
func hotConfigWithClients(clients string) *Config {
cfg := makeHotConfig()
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag == "inbound-1080" {
cfg.InboundConfigs[i].Settings = json_util.RawMessage(`{"clients":` + clients + `}`)
}
}
return cfg
}
// 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 {
cfg := makeHotConfig()
ib := &cfg.InboundConfigs[1]
ib.Protocol = protocol
ib.Settings = json_util.RawMessage(`{"clients":` + clients + `}`)
return cfg
}
both := `[{"email":"a@x","password":"pa"},{"email":"b@x","password":"pb"}]`
onlyA := `[{"email":"a@x","password":"pa"}]`
diff, ok := ComputeHotDiff(withClients(both), withClients(onlyA))
if !ok {
t.Fatal("a dropped client must stay API-applicable")
}
if !diff.DropsUsers() {
t.Fatalf("DropsUsers = false for a dropped %s client (removed=%+v added=%+v dropped=%+v)",
protocol, diff.RemovedUsers, diff.AddedUsers, diff.DroppedClients)
}
edited, ok := ComputeHotDiff(withClients(both), withClients(`[{"email":"a@x","password":"pa"},{"email":"b@x","password":"pb","level":1}]`))
if !ok {
t.Fatal("a client edit must stay API-applicable")
}
if edited.DropsUsers() {
t.Fatal("an edited client is still served")
}
})
}
}
// A disable or a delete takes the client out of the generated config; an edit
// keeps the email and re-adds it. Only the first leaves sessions running.
func TestHotDiffDropsUsers(t *testing.T) {
cases := []struct {
name string
old string
new string
want bool
}{
{
"client taken out of the config",
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
`[]`,
true,
},
{
"edited in place",
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","limitIp":5,"enable":true}]`,
false,
},
{
"added",
`[]`,
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
false,
},
{
"renamed",
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
`[{"email":"b@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
true,
},
{
"one dropped, one edited",
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true},{"email":"b@x","id":"22222222-2222-2222-2222-222222222222","enable":true}]`,
`[{"email":"b@x","id":"22222222-2222-2222-2222-222222222222","limitIp":5,"enable":true}]`,
true,
},
{
"unchanged",
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
`[{"email":"a@x","id":"11111111-1111-1111-1111-111111111111","enable":true}]`,
false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
diff, ok := ComputeHotDiff(hotConfigWithClients(tc.old), hotConfigWithClients(tc.new))
if !ok {
t.Fatalf("diff of %s -> %s must be API-applicable", tc.old, tc.new)
}
if got := diff.DropsUsers(); got != tc.want {
t.Fatalf("DropsUsers = %v, want %v (removed=%+v added=%+v)",
got, tc.want, diff.RemovedUsers, diff.AddedUsers)
}
})
}
}