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.
This commit is contained in:
MHSanaei
2026-11-01 00:30:14 +01:00
parent 07bbf4788a
commit c3a32c8343
5 changed files with 136 additions and 1 deletions
+3
View File
@@ -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 {
+3
View File
@@ -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) {
@@ -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"])
}
+44
View File
@@ -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)
}
})
}
}
+40 -1
View File
@@ -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
}