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) }); 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) + } +} 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") +}