merge: p92-socks-clients (SK5 分配用户+记账+订阅 socks 链接)
This commit is contained in:
@@ -37,6 +37,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'hysteria',
|
||||
'wireguard',
|
||||
'amneziawg',
|
||||
'socks',
|
||||
'mixed',
|
||||
]);
|
||||
|
||||
const EMPTY: ClientBulkAddFormValues = {
|
||||
|
||||
@@ -61,6 +61,8 @@ const MULTI_CLIENT_PROTOCOLS = new Set([
|
||||
'wireguard',
|
||||
'mtproto',
|
||||
'amneziawg',
|
||||
'socks',
|
||||
'mixed',
|
||||
]);
|
||||
|
||||
const CLIENT_FORM_MODAL_Z_INDEX = 1000;
|
||||
|
||||
@@ -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) });
|
||||
|
||||
+28
-2
@@ -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 ""
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user