From 5efaaef2c22acc1453bb1be96f445127dbcc51fa Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 22:04:49 +0000 Subject: [PATCH] feat(xray): emit attached clients as accounts for socks/mixed inbounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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>>> 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. --- internal/web/service/xray.go | 76 ++++++++- .../web/service/xray_config_socks_test.go | 158 ++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 internal/web/service/xray_config_socks_test.go diff --git a/internal/web/service/xray.go b/internal/web/service/xray.go index b27ac6c..d98d238 100644 --- a/internal/web/service/xray.go +++ b/internal/web/service/xray.go @@ -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>>> 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>>>), 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{} diff --git a/internal/web/service/xray_config_socks_test.go b/internal/web/service/xray_config_socks_test.go new file mode 100644 index 0000000..27f54e4 --- /dev/null +++ b/internal/web/service/xray_config_socks_test.go @@ -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") +}