fix(xray): stop the runtime user API from crashing xray-core

Exercising the whole XrayAPI surface against a real xray-core 26.7.28 (the
version go.mod pins) turned up a way for ordinary panel activity to kill the
core process, plus two smaller mismatches with what the core actually does.

buildUserAccount picked the shadowsocks account type by falling through to a
2022 account whenever the cipher was not one of six hardcoded names. xray's
legacy and 2022 inbounds cast the account they are handed without checking
(proxy/shadowsocks/validator.go, proxy/shadowsocks_2022/inbound_multi.go), so
the wrong type is not an error — it panics the core and drops every connection
on the server. The fallback was reachable without any misconfiguration:
autoRenewClients hands AddUser the client object straight out of the inbound's
settings, where the cipher lives under "method", never "cipher", so every
auto-renewed client on a legacy-cipher shadowsocks inbound took xray down. The
xray-valid aead_* aliases hit it too. The cipher is now read from either key,
matched with the same table (and case-insensitivity) the core's own conf
package uses, and an unrecognized one is an error instead of a guess.

The legacy shadowsocks validator is also the only one that accepts a second
user under an email it already holds, and RemoveUser then drops just one of
them — a disabled or expired client kept connecting. AddUser now drops the
email first on that account type so a single removal fully revokes the client.

GetTraffic skipped every stat the first time it saw it. xray creates a
counter on a user's first use, so that dropped a new client's traffic for a
whole polling interval, as did the counter reset after a core restart. Only
the first poll of a process is a baseline now; later, unseen and rewound
counters both count from zero.

Also fixes three unchecked settings["method"].(string) assertions that panic
the panel on a shadowsocks inbound whose settings carry no method, and bounds
TestRoute's port so an out-of-range value cannot wrap into the uint32 the
core is asked about.

Tests: api_users_e2e_test.go drives add/remove for every protocol against a
real core and asserts it survives each one (skipped unless XRAY_E2E_BINARY is
set); the account-type, traffic-delta and renew paths get unit coverage.
This commit is contained in:
Sanaei
2026-07-28 13:52:10 +02:00
parent 7f7b7e16a4
commit fea6a20f7c
7 changed files with 1019 additions and 29 deletions
+23 -2
View File
@@ -287,6 +287,23 @@ func (s *InboundService) adjustTraffics(tx *gorm.DB, dbClientTraffics []*xray.Cl
return dbClientTraffics, newExpiryByEmail, nil
}
// apiUserFromClient prepares a stored client object for the runtime AddUser
// call. The copy matters twice over: the stored object keeps being mutated and
// marshalled back into the inbound's settings, which must not gain an API-only
// key, and shadowsocks clients carry no cipher of their own — it lives on the
// inbound, and without it the API cannot tell which of xray's two shadowsocks
// account types the running inbound expects.
func apiUserFromClient(client map[string]any, cipher string) map[string]any {
user := maps.Clone(client)
if user == nil {
user = map[string]any{}
}
if cipher != "" {
user["cipher"] = cipher
}
return user
}
func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
// check for time expired
var traffics []*xray.ClientTraffic
@@ -367,6 +384,10 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
if len(clients) == 0 {
continue
}
cipher := ""
if inbounds[inbound_index].Protocol == model.Shadowsocks {
cipher, _ = settings["method"].(string)
}
for client_index := range clients {
c := clients[client_index].(map[string]any)
email, _ := c["email"].(string)
@@ -393,7 +414,7 @@ func (s *InboundService) autoRenewClients(tx *gorm.DB) (bool, int64, error) {
}{
protocol: string(inbounds[inbound_index].Protocol),
tag: inbounds[inbound_index].Tag,
client: c,
client: apiUserFromClient(c, cipher),
})
}
clients[client_index] = any(c)
@@ -603,7 +624,7 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
if err != nil {
return false, err
}
cipher = oldSettings["method"].(string)
cipher, _ = oldSettings["method"].(string)
}
err1 := rt.AddUser(context.Background(), inbound, map[string]any{
"email": client.Email,