Files
3x-ui/internal/web/service/inbound_update_hot_users_test.go
T
MHSanaei a66b360c09 fix(xray): apply shadowsocks client changes in place instead of rebuilding
The per-user hot path only covered vless, vmess, trojan, hysteria and
masque, so every client change on a multi-user shadowsocks inbound - a
node push for an auto-disable, renew, reset or add, a local Edit Inbound
save, or a config-diff hot apply - fell through to DelInbound +
AddInbound, closing the listener and dropping every live connection on
the port.

Shadowsocks joins userDiffableProtocols. The emitted user ops also carry
the inbound's method as "cipher": 2022 clients have no method of their
own, and without it AddUser cannot build the account, which would leave
2022 inbounds rebuilding anyway. Changes outside settings.clients
(method, 2022 server key, network) still differ in the rest of the
settings and still rebuild.

Verified against the pinned core: TestXrayAPI_E2E_Users and the
shadowsocks e2e tests pass with XRAY_E2E_BINARY set.

Closes #6820
2026-10-14 14:16:48 +02:00

284 lines
10 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"sync"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// inboundOpsProbe records every handler and user operation the panel sends the
// core, in order, and fails a remove or the first add the way xray does.
type inboundOpsProbe struct {
fakeNodeRuntime
mu sync.Mutex
ops []string
removeErr error
addErr error
}
func (p *inboundOpsProbe) record(op string) {
p.mu.Lock()
defer p.mu.Unlock()
p.ops = append(p.ops, op)
}
func (p *inboundOpsProbe) AddInbound(context.Context, *model.Inbound) error {
p.record("add-inbound")
return nil
}
func (p *inboundOpsProbe) DelInbound(context.Context, *model.Inbound) error {
p.record("del-inbound")
return nil
}
func (p *inboundOpsProbe) UpdateInbound(context.Context, *model.Inbound, *model.Inbound) error {
p.record("update-inbound")
return nil
}
func (p *inboundOpsProbe) AddUser(_ context.Context, _ *model.Inbound, user map[string]any) error {
email, _ := user["email"].(string)
p.record("add-user " + email)
p.mu.Lock()
defer p.mu.Unlock()
err := p.addErr
p.addErr = nil
return err
}
func (p *inboundOpsProbe) RemoveUser(_ context.Context, _ *model.Inbound, email string) error {
p.record("remove-user " + email)
return p.removeErr
}
func (p *inboundOpsProbe) recorded() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.ops...)
}
func hotUsersClient(email, id string, enable bool) model.Client {
return model.Client{Email: email, ID: id, Enable: enable, CreatedAt: 1, UpdatedAt: 1}
}
// A master pushes the whole inbound for every client it disables or renews; the
// node used to delete and re-add the handler, dropping every live connection.
func TestNodeInboundPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
const (
idA = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a301"
idB = "5f2eb9d6-3a2f-4a55-9812-6ea1e2f7a302"
)
active := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, true)}
bDisabled := []model.Client{hotUsersClient("a", idA, true), hotUsersClient("b", idB, false)}
cases := []struct {
name string
stored, pushed []model.Client
portShift int
enabling bool
removeErr error
addErr error
wantOps []string
}{
{
name: "disabling one client removes only that user", stored: active, pushed: bDisabled,
wantOps: []string{"remove-user b"},
},
{
name: "renewing a disabled client adds only that user", stored: bDisabled, pushed: active,
wantOps: []string{"add-user b"},
},
{
name: "a port change still rebuilds the handler", stored: active, pushed: active, portShift: 1,
wantOps: []string{"del-inbound", "add-inbound"},
},
{
name: "enabling a disabled inbound adds its handler", stored: active, pushed: active, enabling: true,
wantOps: []string{"del-inbound", "add-inbound"},
},
{
name: "a user the core no longer holds is not a rebuild", stored: active, pushed: bDisabled,
removeErr: errors.New("failed to remove user: User b not found."),
wantOps: []string{"remove-user b"},
},
{
name: "a handler the core lost is rebuilt", stored: active, pushed: bDisabled,
removeErr: errors.New("failed to remove user: failed to get handler: hot-users > handler not found: hot-users"),
wantOps: []string{"remove-user b", "del-inbound", "add-inbound"},
},
{
name: "a user the core already holds is replaced", stored: bDisabled, pushed: active,
addErr: errors.New("User b already exists."),
wantOps: []string{"add-user b", "remove-user b", "add-user b"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setupConflictDB(t)
// A node keeps this default; the master's own setting decides node restarts.
setRestartOnClientDisable(t, true)
probe := &inboundOpsProbe{removeErr: tc.removeErr, addErr: tc.addErr}
useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
seedInboundConflict(t, "hot-users", "0.0.0.0", 50081, model.VLESS, `{"network":"tcp"}`, clientsSettings(t, tc.stored))
stored := loadInboundByTag(t, "hot-users")
if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
if tc.enabling {
if err := database.GetDB().Model(stored).Update("enable", false).Error; err != nil {
t.Fatalf("disable stored inbound: %v", err)
}
}
pushed := *stored
pushed.Settings = clientsSettings(t, tc.pushed)
pushed.Port += tc.portShift
pushed.Enable = true
_, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
}
if needRestart {
t.Fatal("needRestart = true, want the push applied without restarting the core")
}
})
}
}
// AmneziaWG has its own in-place reconfigure; deleting and re-adding the inbound
// rebuilt the device and dropped every peer's session on each pushed client change.
func TestNodeAmneziaWGPushReconfiguresTheDeviceInPlace(t *testing.T) {
setupConflictDB(t)
probe := &inboundOpsProbe{}
useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
settings := awgRelayWindowSettings(t, "hot-awg")
seedInboundConflict(t, "hot-awg", "0.0.0.0", 51830, model.AmneziaWG, "", settings)
stored := loadInboundByTag(t, "hot-awg")
clients, err := (&InboundService{}).GetClients(stored)
if err != nil {
t.Fatalf("GetClients: %v", err)
}
if err := (&ClientService{}).SyncInbound(nil, stored.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
pushed := *stored
pushed.Settings = strings.Replace(settings, `"enable":true`, `"enable":false`, 1)
if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed); err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if got, want := probe.recorded(), []string{"update-inbound"}; !slices.Equal(got, want) {
t.Fatalf("runtime operations = %q, want %q", got, want)
}
}
// A master re-sends every inbound after it restarts; an unchanged one must not
// touch the core, even for a protocol whose users have no per-user API path.
func TestNodeInboundUnchangedPushLeavesTheCoreAlone(t *testing.T) {
setupConflictDB(t)
probe := &inboundOpsProbe{}
useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
clients := []model.Client{{Email: "ss-a", Password: "a-pass", Enable: true, CreatedAt: 1, UpdatedAt: 1}}
settings := `{"method":"chacha20-ietf-poly1305","network":"tcp,udp","clients":[{"method":"chacha20-ietf-poly1305","password":"a-pass","email":"ss-a","enable":true,"created_at":1,"updated_at":1}]}`
seedInboundConflict(t, "hot-ss", "0.0.0.0", 50082, model.Shadowsocks, `{"network":"tcp"}`, settings)
stored := loadInboundByTag(t, "hot-ss")
if err := (&ClientService{}).SyncInbound(nil, stored.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
pushed := *stored
if _, _, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed); err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if got := probe.recorded(); len(got) != 0 {
t.Fatalf("core operations = %q, want none for an unchanged inbound", got)
}
}
func ssHotSettings(t *testing.T, method, serverKey string, clients []model.Client) string {
t.Helper()
var settings map[string]any
if err := json.Unmarshal([]byte(clientsSettings(t, clients)), &settings); err != nil {
t.Fatalf("unmarshal settings: %v", err)
}
settings["method"] = method
settings["network"] = "tcp,udp"
if serverKey != "" {
settings["password"] = serverKey
} else {
for _, c := range settings["clients"].([]any) {
c.(map[string]any)["method"] = method
}
}
out, err := json.Marshal(settings)
if err != nil {
t.Fatalf("marshal settings: %v", err)
}
return string(out)
}
// Multi-user shadowsocks has the same per-user API as vless, so a pushed client
// change must not rebuild the handler and drop every connection on the port.
func TestNodeShadowsocksPushChangingOnlyClientsKeepsTheHandler(t *testing.T) {
ciphers := []struct{ name, method, serverKey, keyA, keyB string }{
{"legacy aead", "aes-256-gcm", "", "a-pass", "b-pass"},
{"2022", "2022-blake3-aes-128-gcm", "AAECAwQFBgcICQoLDA0ODw==", "EBESExQVFhcYGRobHB0eHw==", "ICEiIyQlJicoKSorLC0uLw=="},
}
for _, cipher := range ciphers {
a := model.Client{Email: "ss-a", Password: cipher.keyA, Enable: true, CreatedAt: 1, UpdatedAt: 1}
b := model.Client{Email: "ss-b", Password: cipher.keyB, Enable: true, CreatedAt: 1, UpdatedAt: 1}
bOff := b
bOff.Enable = false
cases := []struct {
name string
stored, pushed []model.Client
wantOps []string
}{
{"disabling one client removes only that user", []model.Client{a, b}, []model.Client{a, bOff}, []string{"remove-user ss-b"}},
{"renewing a disabled client adds only that user", []model.Client{a, bOff}, []model.Client{a, b}, []string{"add-user ss-b"}},
}
for _, tc := range cases {
t.Run(cipher.name+"/"+tc.name, func(t *testing.T) {
setupConflictDB(t)
setRestartOnClientDisable(t, true)
probe := &inboundOpsProbe{}
useTestRuntimeManager(t).SetLocalRuntimeOverride(probe)
seedInboundConflict(t, "hot-ss", "0.0.0.0", 50083, model.Shadowsocks, `{"network":"tcp"}`,
ssHotSettings(t, cipher.method, cipher.serverKey, tc.stored))
stored := loadInboundByTag(t, "hot-ss")
if err := (&ClientService{}).SyncInbound(nil, stored.Id, tc.stored); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
pushed := *stored
pushed.Settings = ssHotSettings(t, cipher.method, cipher.serverKey, tc.pushed)
_, needRestart, err := (&InboundService{FromNodeSync: true}).UpdateInbound(&pushed)
if err != nil {
t.Fatalf("UpdateInbound: %v", err)
}
if got := probe.recorded(); !slices.Equal(got, tc.wantOps) {
t.Fatalf("core operations = %q, want %q", got, tc.wantOps)
}
if needRestart {
t.Fatal("needRestart = true, want the push applied without restarting the core")
}
})
}
}
}