Files
3x-ui-agent/internal/web/service/xray_config_socks_test.go
Muse 5efaaef2c2 feat(xray): emit attached clients as accounts for socks/mixed inbounds
Socks-family inbounds authenticate against settings.accounts, so the
clients attached to them never reached the core: the generated config
carried the settings' "clients" mirror (ignored by xray for these
protocols, passwords and all) and only the inbound's own fixed
account. Emit one account per enabled attached client instead —
user = client email, pass = client password — appended after the
inbound's stored accounts, which keep their order and win name
collisions (managed-forwarding resolution reads the fixed account
from the stored settings, untouched). Drop the clients mirror from
the generated settings, and force auth=password when clients exist,
since accounts are inert under noauth.

Accounting needs no new plumbing: xray-core 26.6.27 reports these
accounts under user>>><account user> counters (probe-verified with
two accounts on both mixed and socks), and the agent snapshot ->
report -> AddTraffic chain already keys client traffic by that exact
string, so making the account name the client email aligns booking
end to end. Clientless socks-family inbounds pass through verbatim.
2026-10-21 22:04:49 +00:00

159 lines
5.3 KiB
Go

package service
import (
"encoding/json"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
// seedSocksInbound homes a socks-family inbound (fixed landing account in
// its stored settings) on the shared clients-test agent node and attaches
// the given clients through the DB link tables, the same source the
// running config is built from.
func seedSocksInbound(t *testing.T, tag string, port int, protocol model.Protocol, settings string, clients []model.Client) {
t.Helper()
setupSettingTestDB(t)
db := database.GetDB()
nodeID := clientsTestNodeID(t, db)
in := &model.Inbound{
Tag: tag,
Enable: true,
Port: port,
Protocol: protocol,
Settings: settings,
NodeID: &nodeID,
}
if err := db.Create(in).Error; err != nil {
t.Fatalf("create socks inbound: %v", err)
}
svc := ClientService{}
if err := svc.SyncInbound(nil, in.Id, clients); err != nil {
t.Fatalf("SyncInbound: %v", err)
}
}
func emittedSettings(t *testing.T, tag string) map[string]any {
t.Helper()
svc := &XrayService{}
cfg, err := svc.GetXrayConfigForAgent(clientsTestNodeID(t, database.GetDB()), "", "")
if err != nil {
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag != tag {
continue
}
var s map[string]any
if err := json.Unmarshal([]byte(cfg.InboundConfigs[i].Settings), &s); err != nil {
t.Fatalf("unmarshal emitted settings: %v", err)
}
return s
}
t.Fatalf("inbound %q not found in generated config", tag)
return nil
}
func accountUsers(t *testing.T, settings map[string]any) []map[string]any {
t.Helper()
raw, ok := settings["accounts"].([]any)
if !ok {
t.Fatalf("settings.accounts must be an array, got %T", settings["accounts"])
}
out := make([]map[string]any, 0, len(raw))
for _, a := range raw {
m, ok := a.(map[string]any)
if !ok {
t.Fatalf("account entry must be an object, got %T", a)
}
out = append(out, m)
}
return out
}
func TestGetXrayConfig_SocksClientsEmittedAsAccounts(t *testing.T) {
seedSocksInbound(t, "mixed-clients", 43201, model.Mixed,
`{"auth":"password","accounts":[{"user":"fixed@landing","pass":"fixed-pass"}],"udp":true}`,
[]model.Client{
{Email: "user1@x", Password: "pass-1", Enable: true},
{Email: "user2@x", Password: "pass-2", Enable: true},
{Email: "off@x", Password: "pass-3", Enable: true},
})
disableClients(t, "off@x")
settings := emittedSettings(t, "mixed-clients")
if _, ok := settings["clients"]; ok {
t.Fatal("settings.clients must not be emitted for a mixed inbound")
}
accounts := accountUsers(t, settings)
if len(accounts) != 3 {
t.Fatalf("expected fixed account + both enabled clients (disabled one excluded), got %d: %#v", len(accounts), accounts)
}
if accounts[0]["user"] != "fixed@landing" || accounts[0]["pass"] != "fixed-pass" {
t.Errorf("the inbound's own account must stay first and untouched: %#v", accounts[0])
}
if accounts[1]["user"] != "user1@x" || accounts[1]["pass"] != "pass-1" {
t.Errorf("client account must be keyed by email with the client password: %#v", accounts[1])
}
if accounts[2]["user"] != "user2@x" || accounts[2]["pass"] != "pass-2" {
t.Errorf("second client account must follow in order: %#v", accounts[2])
}
if settings["auth"] != "password" {
t.Errorf("auth must be password when clients are attached, got %v", settings["auth"])
}
}
func TestGetXrayConfig_SocksClientsForcePasswordAuth(t *testing.T) {
seedSocksInbound(t, "mixed-noauth", 43202, model.Mixed,
`{"auth":"noauth","udp":true}`,
[]model.Client{
{Email: "user1@x", Password: "pass-1", Enable: true},
})
settings := emittedSettings(t, "mixed-noauth")
accounts := accountUsers(t, settings)
if len(accounts) != 1 || accounts[0]["user"] != "user1@x" {
t.Fatalf("expected the single client account, got %#v", accounts)
}
if settings["auth"] != "password" {
t.Errorf("attaching clients must switch a noauth inbound to password auth, got %v", settings["auth"])
}
}
func TestGetXrayConfig_LegacySocksClientsEmittedAsAccounts(t *testing.T) {
seedSocksInbound(t, "socks-legacy", 43203, model.Protocol("socks"),
`{"auth":"password","accounts":[{"user":"fixed@landing","pass":"fixed-pass"}],"udp":true}`,
[]model.Client{
{Email: "user1@x", Password: "pass-1", Enable: true},
})
settings := emittedSettings(t, "socks-legacy")
accounts := accountUsers(t, settings)
if len(accounts) != 2 || accounts[1]["user"] != "user1@x" {
t.Fatalf("legacy socks rows must emit client accounts too, got %#v", accounts)
}
}
func TestGetXrayConfig_SocksWithoutClientsUntouched(t *testing.T) {
stored := `{"auth":"password","accounts":[{"user":"fixed@landing","pass":"fixed-pass"}],"udp":true}`
seedSocksInbound(t, "mixed-bare", 43204, model.Mixed, stored, nil)
svc := &XrayService{}
cfg, err := svc.GetXrayConfigForAgent(clientsTestNodeID(t, database.GetDB()), "", "")
if err != nil {
t.Fatalf("GetXrayConfigForAgent: %v", err)
}
for i := range cfg.InboundConfigs {
if cfg.InboundConfigs[i].Tag != "mixed-bare" {
continue
}
if string(cfg.InboundConfigs[i].Settings) != stored {
t.Fatalf("a clientless mixed inbound's settings must pass through verbatim:\nstored: %s\nemitted: %s",
stored, cfg.InboundConfigs[i].Settings)
}
return
}
t.Fatal("inbound mixed-bare not found in generated config")
}