From bea7f4b2b60746375c2ef87f4937faec163de5fe Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 22:01:30 +0000 Subject: [PATCH 1/3] feat(clients): allow attaching clients to socks/mixed inbounds The client form, bulk-add form and the inbound-side attach entry all filtered attachable inbounds by a protocol whitelist that lacked socks/mixed (inherited from upstream), so SK5 inbounds could never be picked as a client's associated inbound even though the backend accepts the association. Add 'socks' and 'mixed' to all three gates. The client record's existing password field is the socks credential; no new credential shape is introduced. --- frontend/src/pages/clients/ClientBulkAddModal.tsx | 2 ++ frontend/src/pages/clients/ClientFormModal.tsx | 2 ++ frontend/src/pages/inbounds/list/helpers.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index 95ab17c..104f02d 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/frontend/src/pages/clients/ClientBulkAddModal.tsx @@ -37,6 +37,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([ 'hysteria', 'wireguard', 'amneziawg', + 'socks', + 'mixed', ]); const EMPTY: ClientBulkAddFormValues = { diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index f7b28f4..e6e1605 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/frontend/src/pages/clients/ClientFormModal.tsx @@ -61,6 +61,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([ 'wireguard', 'mtproto', 'amneziawg', + 'socks', + 'mixed', ]); const CLIENT_FORM_MODAL_Z_INDEX = 1000; diff --git a/frontend/src/pages/inbounds/list/helpers.ts b/frontend/src/pages/inbounds/list/helpers.ts index 7526593..5525ea0 100644 --- a/frontend/src/pages/inbounds/list/helpers.ts +++ b/frontend/src/pages/inbounds/list/helpers.ts @@ -90,6 +90,8 @@ export function isInboundMultiUser(record: { protocol: string; settings: unknown case 'mtproto': case 'wireguard': case 'amneziawg': + case 'socks': + case 'mixed': return true; case 'shadowsocks': return isSSMultiUser({ protocol: 'shadowsocks', settings: readSettings(record.settings) }); From 5efaaef2c22acc1453bb1be96f445127dbcc51fa Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 22:04:49 +0000 Subject: [PATCH 2/3] 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") +} From 8ca1a4647a7e449669b38e3343daf1354694fa98 Mon Sep 17 00:00:00 2001 From: Muse Date: Wed, 21 Oct 2026 22:07:06 +0000 Subject: [PATCH 3/3] feat(sub): emit socks:// links for clients on socks/mixed inbounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetLink had no socks-family case, so an attached client's subscription contained no link for the inbound at all, and getInboundsBySubId's protocol list omitted socks/mixed entirely, so such a subscription 404'd when the SK5 inbound was the client's only one. genSocksLink emits socks://user:pass@host:port with the same credential pair the core config carries — user is the client email, pass is the client password, both percent-encoded. The inbound's own fixed account is an operator credential and is never emitted. --- internal/sub/service.go | 30 +++++++++- internal/sub/socks_link_test.go | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 internal/sub/socks_link_test.go diff --git a/internal/sub/service.go b/internal/sub/service.go index cc90e31..f7fdbd1 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -484,7 +484,7 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error) JOIN client_inbounds ON client_inbounds.inbound_id = inbounds.id JOIN clients ON clients.id = client_inbounds.client_id WHERE - inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto') + inbounds.protocol in ('vmess','vless','trojan','shadowsocks','hysteria','wireguard','amneziawg','mtproto','socks','mixed') AND clients.sub_id = ? AND inbounds.enable = ? )`, subId, true).Order("sub_sort_index ASC").Order("id ASC").Find(&inbounds).Error if err != nil { @@ -617,7 +617,7 @@ func mergeStreamFromMaster(childStream, masterStream string) string { // GetLink dispatches to the protocol-specific generator for one (inbound, client) // pair. Returns "" when the inbound's protocol doesn't produce a subscription URL -// (socks, http, mixed, wireguard, dokodemo, tunnel). The returned string may +// (http, wireguard, dokodemo, tunnel). The returned string may // contain multiple `\n`-separated URLs when the inbound has externalProxy set. func (s *SubService) GetLink(inbound *model.Inbound, email string) string { switch inbound.Protocol { @@ -629,6 +629,8 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string { return s.genTrojanLink(inbound, email) case "shadowsocks": return s.genShadowsocksLink(inbound, email) + case "socks", "mixed": + return s.genSocksLink(inbound, email) case "hysteria": return s.genHysteriaLink(inbound, email) case "mtproto": @@ -1126,6 +1128,30 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st return buildLinkWithParams(link, params, s.genRemark(inbound, email, "", streamNetwork)) } +// genSocksLink builds socks://user:pass@host:port for a client attached +// to a socks-family inbound: the account name is the client email and +// the password is the client's password, exactly the pair the generated +// core config carries in settings.accounts. Userinfo parts are +// percent-encoded (client emails contain '@'). Inbounds with no client +// attached produce no link — the inbound's own fixed account is an +// operator credential, not a subscription identity. +func (s *SubService) genSocksLink(inbound *model.Inbound, email string) string { + if inbound.Protocol != model.Mixed && inbound.Protocol != model.Protocol("socks") { + return "" + } + address := s.resolveInboundAddress(inbound) + client, ok := s.clientForLink(inbound, email) + if !ok { + return "" + } + userInfo := encodeUserinfo(client.Email) + if client.Password != "" { + userInfo += ":" + encodeUserinfo(client.Password) + } + link := fmt.Sprintf("socks://%s@%s", userInfo, joinHostPort(address, inbound.Port)) + return buildLinkWithParams(link, nil, s.genRemark(inbound, email, "", "tcp")) +} + func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Hysteria { return "" diff --git a/internal/sub/socks_link_test.go b/internal/sub/socks_link_test.go new file mode 100644 index 0000000..13020f1 --- /dev/null +++ b/internal/sub/socks_link_test.go @@ -0,0 +1,101 @@ +package sub + +import ( + "strings" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) + +// Socks-family inbounds produce a per-client socks:// link: the account +// name is the client email, the password is the client's password — the +// same pair the generated core config carries in settings.accounts. +func TestGetLink_SocksFamily(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + settings := `{"auth":"password","accounts":[{"user":"fixed@landing","pass":"fixed-pass"}],"udp":true,` + + `"clients":[{"email":"user1@x","password":"pass-1","subId":"sub1","enable":true}]}` + ib := &model.Inbound{ + UserId: 1, Tag: "sk5", Enable: true, Listen: "203.0.113.9", Port: 32080, + Protocol: model.Mixed, Remark: "sk5-mixed", Settings: settings, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + + svc := NewSubService("") + svc.PrepareForRequest("req.example.com") + + link := svc.GetLink(ib, "user1@x") + wantPrefix := "socks://user1%40x:pass-1@203.0.113.9:32080" + if !strings.HasPrefix(link, wantPrefix) { + t.Fatalf("socks link = %q, want prefix %q", link, wantPrefix) + } + if strings.Contains(link, "fixed@landing") || strings.Contains(link, "fixed-pass") { + t.Fatalf("the inbound's fixed account must never leak into a client link: %q", link) + } + + if got := svc.GetLink(ib, "nobody@x"); got != "" { + t.Fatalf("unknown client must produce no link, got %q", got) + } + + links := svc.inboundLinks(ib) + if len(links) != 1 || !strings.HasPrefix(links[0], wantPrefix) { + t.Fatalf("inboundLinks must enumerate the socks client link, got %v", links) + } +} + +// The full subscription path must resolve a socks-family inbound too: +// getInboundsBySubId gates on a protocol list that historically omitted +// socks/mixed, which made such subscriptions 404 even with a link +// generator in place. +func TestGetSubs_SocksFamily(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + settings := `{"auth":"password","accounts":[{"user":"fixed@landing","pass":"fixed-pass"}],"udp":true,` + + `"clients":[{"email":"user1@x","password":"pass-1","subId":"sub1","enable":true}]}` + ib := &model.Inbound{ + UserId: 1, Tag: "sk5", Enable: true, Listen: "203.0.113.9", Port: 32080, + Protocol: model.Mixed, Remark: "sk5-mixed", Settings: settings, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + rec := &model.ClientRecord{Email: "user1@x", SubID: "sub1", Password: "pass-1", Enable: true} + if err := db.Create(rec).Error; err != nil { + t.Fatalf("seed client: %v", err) + } + if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ib.Id}).Error; err != nil { + t.Fatalf("seed link: %v", err) + } + + svc := NewSubService("") + links, _, _, _, err := svc.GetSubs("sub1", "req.example.com") + if err != nil { + t.Fatalf("GetSubs: %v", err) + } + if len(links) != 1 || !strings.HasPrefix(links[0], "socks://user1%40x:pass-1@203.0.113.9:32080") { + t.Fatalf("GetSubs must return the socks link, got %v", links) + } +} + +func TestGetLink_LegacySocksProtocol(t *testing.T) { + seedSubDB(t) + db := database.GetDB() + settings := `{"auth":"password","accounts":[],"clients":[{"email":"user1@x","password":"pass-1","subId":"sub1","enable":true}]}` + ib := &model.Inbound{ + UserId: 1, Tag: "sk5-old", Enable: true, Listen: "203.0.113.9", Port: 32081, + Protocol: model.Protocol("socks"), Remark: "sk5-old", Settings: settings, + } + if err := db.Create(ib).Error; err != nil { + t.Fatalf("seed inbound: %v", err) + } + + svc := NewSubService("") + svc.PrepareForRequest("req.example.com") + link := svc.GetLink(ib, "user1@x") + if !strings.HasPrefix(link, "socks://user1%40x:pass-1@203.0.113.9:32081") { + t.Fatalf("legacy socks link = %q", link) + } +}