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