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.
This commit is contained in:
Muse
2026-10-21 22:04:49 +00:00
parent bea7f4b2b6
commit 5efaaef2c2
2 changed files with 233 additions and 1 deletions
+75 -1
View File
@@ -36,6 +36,63 @@ func (s *XrayService) GetXrayConfigForAgent(nodeID int, outboundsJSON, routingRu
return s.buildAgentXrayConfig(nodeID, outboundsJSON, routingRulesJSON)
}
// isSocksFamilyProtocol reports whether the protocol authenticates with
// settings.accounts (user/pass pairs) instead of settings.clients:
// "mixed" natively, plus "socks" rows that arrive via node sync from
// stock panels (this fork has no native socks inbound form).
func isSocksFamilyProtocol(p model.Protocol) bool {
return p == model.Mixed || p == model.Protocol("socks")
}
// applySocksClientAccounts folds the DB clients attached to a
// socks-family inbound into its generated settings as accounts. Xray
// reports these inbounds' traffic under user>>><account user> counters,
// so the account name is the client's email — the identity the panel
// books traffic by — and the password is the client's password
// (verified against xray-core 26.6.27 stats output). The inbound's own
// accounts from the stored settings keep their order and win name
// collisions, so the fixed landing account that managed-forwarding
// resolution reads from the settings stays first. The settings'
// "clients" mirror is dropped from the generated config: the core
// ignores it for these protocols and it would only ship the client
// records (passwords included) to the agent for nothing. Returns
// whether the settings changed.
func applySocksClientAccounts(settings map[string]any, clientAccounts []any) bool {
mirror, _ := settings["clients"].([]any)
if len(mirror) == 0 && len(clientAccounts) == 0 {
return false
}
delete(settings, "clients")
existing, _ := settings["accounts"].([]any)
seen := make(map[string]bool, len(existing)+len(clientAccounts))
merged := make([]any, 0, len(existing)+len(clientAccounts))
for _, acc := range existing {
merged = append(merged, acc)
if m, ok := acc.(map[string]any); ok {
if user, _ := m["user"].(string); user != "" {
seen[user] = true
}
}
}
for _, acc := range clientAccounts {
m, _ := acc.(map[string]any)
user, _ := m["user"].(string)
if user == "" || seen[user] {
continue
}
seen[user] = true
merged = append(merged, acc)
}
settings["accounts"] = merged
// Attached clients only mean anything under password auth; without
// it the core ignores the accounts entirely.
if len(clientAccounts) > 0 {
settings["auth"] = "password"
}
return true
}
func (s *XrayService) buildAgentXrayConfig(nodeID int, outboundsJSON, routingRulesJSON string) (*xray.Config, error) {
templateConfig, err := s.settingService.GetXrayConfigTemplate()
if err != nil {
@@ -89,6 +146,7 @@ func (s *XrayService) buildAgentXrayConfig(nodeID int, outboundsJSON, routingRul
finalClients := make([]any, 0, len(dbClients))
var wgPeers []any
var socksAccounts []any
for i := range dbClients {
c := dbClients[i]
if enable, exists := enableMap[c.Email]; exists && !enable {
@@ -139,6 +197,18 @@ func (s *XrayService) buildAgentXrayConfig(nodeID int, outboundsJSON, routingRul
if c.Auth != "" {
entry["auth"] = c.Auth
}
case model.Mixed, model.Protocol("socks"):
// Socks-family inbounds authenticate against
// settings.accounts, not settings.clients: one account
// per client, named by the client email — xray keys
// its per-account traffic counters by that name
// (user>>><account user>), which is the identity the
// master books traffic by.
socksAccounts = append(socksAccounts, map[string]any{
"user": c.Email,
"pass": c.Password,
})
continue
case model.WireGuard:
wgPeers = append(wgPeers, model.WireguardPeerFromClient(c))
continue
@@ -147,7 +217,11 @@ func (s *XrayService) buildAgentXrayConfig(nodeID int, outboundsJSON, routingRul
}
var mutated bool
if inbound.Protocol == model.WireGuard {
if isSocksFamilyProtocol(inbound.Protocol) {
if applySocksClientAccounts(settings, socksAccounts) {
mutated = true
}
} else if inbound.Protocol == model.WireGuard {
delete(settings, "clients")
if wgPeers == nil {
wgPeers = []any{}
@@ -0,0 +1,158 @@
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")
}