From c3a32c8343b2a4180489b5ffa7f3e01dd21102d1 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Sun, 1 Nov 2026 00:30:14 +0100 Subject: [PATCH] fix(shadowsocks): keep a 2022 inbound with no enabled client locked When every client of a Shadowsocks-2022 inbound was disabled the panel handed the core an empty client list, from which xray-core builds a single-user server keyed by the inbound's server key alone. That key is the first half of every link the panel ever issued, so a client cut off for quota, expiry or by the operator connected again with it. An inbound left without clients now gets a placeholder user whose key is random and held only in memory, in the config and in the running core alike. The core stays multi-user, and disabling the last real client takes the same remove-and-restart path as any other client, so its live sessions end as well. --- internal/web/service/inbound.go | 3 ++ internal/web/service/xray.go | 3 ++ .../web/service/xray_config_clients_test.go | 46 +++++++++++++++++++ internal/xray/api_shadowsocks_test.go | 44 ++++++++++++++++++ internal/xray/shadowsocks2022.go | 41 ++++++++++++++++- 5 files changed, 136 insertions(+), 1 deletion(-) diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 8181d62a1..408fa0cd2 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -2508,6 +2508,9 @@ func (s *InboundService) stripDisabledClients(tx *gorm.DB, inbound *model.Inboun finalClients = append(finalClients, c) } settings["clients"] = finalClients + if inbound.Protocol == model.Shadowsocks { + xray.LockShadowsocks2022(settings) + } modifiedSettings, err := json.MarshalIndent(settings, "", " ") if err != nil { diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index 596986c33..462a880a0 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -329,6 +329,9 @@ func (s *XrayService) GetXrayConfig() (*xray.Config, error) { if mutated { settings["clients"] = finalClients } + if inbound.Protocol == model.Shadowsocks && xray.LockShadowsocks2022(settings) { + mutated = true + } } if inboundCanHostFallbacks(inbound) { diff --git a/internal/web/service/xray_config_clients_test.go b/internal/web/service/xray_config_clients_test.go index 2d3e9437d..585d60b25 100644 --- a/internal/web/service/xray_config_clients_test.go +++ b/internal/web/service/xray_config_clients_test.go @@ -6,6 +6,7 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" ) func seedVlessInbound(t *testing.T, tag string, port int, clients []model.Client) { @@ -179,3 +180,48 @@ func TestGetXrayConfig_KeepsVisionFlowWhereTheCoreRunsIt(t *testing.T) { }) } } + +// With an empty client list xray-core runs a Shadowsocks-2022 inbound as a single-user +// server, and the server key it then accepts alone is the first half of every client's password. +func TestShadowsocks2022WithoutEnabledClientsStaysLocked(t *testing.T) { + setupSettingTestDB(t) + key := "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + in := &model.Inbound{ + Tag: "ss-2022", Enable: true, Port: 43110, Protocol: model.Shadowsocks, + Settings: `{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","network":"tcp,udp","clients":[]}`, + } + if err := database.GetDB().Create(in).Error; err != nil { + t.Fatalf("create shadowsocks inbound: %v", err) + } + if err := (&ClientService{}).SyncInbound(nil, in.Id, []model.Client{{Email: "only@x", Password: key, Enable: true}}); err != nil { + t.Fatalf("SyncInbound: %v", err) + } + disableClients(t, "only@x") + + onlyLocked := func(where string, clients any) { + t.Helper() + list, _ := clients.([]any) + if len(list) != 1 { + t.Fatalf("%s: clients = %v, want only the locked placeholder", where, clients) + } + if email, _ := list[0].(map[string]any)["email"].(string); email != xray.LockedShadowsocks2022Email { + t.Fatalf("%s: client %v served, want only the locked placeholder", where, list[0]) + } + } + emitted, _ := emittedClients(t, in.Tag) + onlyLocked("config", emitted) + + var stored model.Inbound + if err := database.GetDB().First(&stored, in.Id).Error; err != nil { + t.Fatalf("read inbound: %v", err) + } + runtimeInbound, err := (&InboundService{}).buildInboundForLocalRuntime(database.GetDB(), &stored) + if err != nil { + t.Fatalf("buildInboundForLocalRuntime: %v", err) + } + var settings map[string]any + if err := json.Unmarshal([]byte(runtimeInbound.Settings), &settings); err != nil { + t.Fatalf("unmarshal runtime settings: %v", err) + } + onlyLocked("local runtime", settings["clients"]) +} diff --git a/internal/xray/api_shadowsocks_test.go b/internal/xray/api_shadowsocks_test.go index 69186a56a..098242d57 100644 --- a/internal/xray/api_shadowsocks_test.go +++ b/internal/xray/api_shadowsocks_test.go @@ -1,9 +1,11 @@ package xray import ( + "encoding/json" "strings" "testing" + "github.com/xtls/xray-core/infra/conf" "github.com/xtls/xray-core/proxy/shadowsocks" "github.com/xtls/xray-core/proxy/shadowsocks_2022" "google.golang.org/protobuf/proto" @@ -147,3 +149,45 @@ func TestBuildUserAccountShadowsocksMissingPassword(t *testing.T) { t.Fatal("expected an error for a shadowsocks user without a password") } } + +// With no client the core builds a single-user Shadowsocks-2022 server keyed by the server +// key alone, which is the first half of every link handed out; the lock must prevent that. +func TestLockShadowsocks2022KeepsTheCoreMultiUser(t *testing.T) { + const key = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=" + const user = `[{"email":"a@x","password":"` + key + `"}]` + for name, tc := range map[string]struct { + settings string + locks bool + }{ + "empty clients": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","clients":[]}`, true}, + "no clients key": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `"}`, true}, + "upper-case method": {`{"method":"2022-BLAKE3-AES-256-GCM","password":"` + key + `","clients":[]}`, true}, + "aes-128": {`{"method":"2022-blake3-aes-128-gcm","password":"MDEyMzQ1Njc4OWFiY2RlZg==","clients":[]}`, true}, + "empty clients hide users": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","clients":[],"users":` + user + `}`, true}, + "one client": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","clients":` + user + `}`, false}, + "users only": {`{"method":"2022-blake3-aes-256-gcm","password":"` + key + `","users":` + user + `}`, false}, + "legacy method": {`{"method":"aes-256-gcm","password":"x","clients":[]}`, false}, + } { + t.Run(name, func(t *testing.T) { + var settings map[string]any + if err := json.Unmarshal([]byte(tc.settings), &settings); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if got := LockShadowsocks2022(settings); got != tc.locks { + t.Fatalf("LockShadowsocks2022 = %v, want %v", got, tc.locks) + } + raw, _ := json.Marshal(settings) + var parsed conf.ShadowsocksServerConfig + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("unmarshal locked settings: %v", err) + } + built, err := parsed.Build() + if err != nil { + t.Fatalf("core refuses the locked settings: %v", err) + } + if _, singleUser := built.(*shadowsocks_2022.ServerConfig); singleUser { + t.Fatalf("core serves %s to the server key alone", name) + } + }) + } +} diff --git a/internal/xray/shadowsocks2022.go b/internal/xray/shadowsocks2022.go index f710ae454..93aa2db22 100644 --- a/internal/xray/shadowsocks2022.go +++ b/internal/xray/shadowsocks2022.go @@ -1,6 +1,12 @@ package xray -import "github.com/xtls/xray-core/proxy/shadowsocks_2022" +import ( + "crypto/rand" + "encoding/base64" + "sync" + + "github.com/xtls/xray-core/proxy/shadowsocks_2022" +) // Shadowsocks2022KeyBytes is the key size a Shadowsocks-2022 method takes, or 0 // for any other method, whose password is free-form. @@ -25,3 +31,36 @@ func Shadowsocks2022NoMultiUser(method string) bool { cipher, err := shadowsocks_2022.GetCipherMethod(method) return err == nil && cipher.IsChaCha } + +// LockedShadowsocks2022Email names the placeholder user; the panel refuses "/" in a +// client email, so it never collides with a real one. +const LockedShadowsocks2022Email = "/locked" + +var lockedShadowsocks2022Key = sync.OnceValue(func() []byte { + key := make([]byte, 32) + _, _ = rand.Read(key) + return key +}) + +// LockShadowsocks2022 gives a Shadowsocks-2022 inbound left with no client a user whose key +// no link carries: with none, the core serves the inbound to its server key alone. +func LockShadowsocks2022(settings map[string]any) bool { + method, _ := settings["method"].(string) + n := Shadowsocks2022KeyBytes(method) + if n == 0 { + return false + } + // The core reads clients over users, so users count only when clients is absent. + users, hasClients := settings["clients"] + if !hasClients { + users = settings["users"] + } + if list, _ := users.([]any); len(list) > 0 { + return false + } + settings["clients"] = []any{map[string]any{ + "email": LockedShadowsocks2022Email, + "password": base64.StdEncoding.EncodeToString(lockedShadowsocks2022Key()[:n]), + }} + return true +}