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