diff --git a/panel-go/handlers.go b/panel-go/handlers.go index 4e72ffa..6aa8a3e 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -569,6 +569,30 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error } } } + // Round-3 item 21: a socks5-auth node carries its own fixed account. + // Fields merge over the stored pair (blank keeps the old value, the + // form's convention); a pair still missing afterwards is minted by + // EnsureNodeSecrets on save. The username must not start with "ng:" + // — that prefix is the per-customer identity namespace the agent + // maps to usage samples. + if protocol == "socks5-auth" { + if v, ok := body["socksUser"]; ok { + if user := CleanText(v, 128); user != "" { + if strings.HasPrefix(user, "ng:") || strings.ContainsAny(user, " \t\r\n") { + return nil, httpErr(400, "SOCKS5 用户名不能以 ng: 开头或包含空白字符") + } + next.SocksUser = user + } + } + if v, ok := body["socksPassword"]; ok { + if password := CleanText(v, 512); password != "" { + next.SocksPassword = password + } + } + } else { + next.SocksUser = "" + next.SocksPassword = "" + } if IsRealityProtocol(protocol) && next.RealityServerName == "" { return nil, httpErr(400, "Reality SNI 不能为空") } @@ -755,7 +779,9 @@ func customerCredentialsView(d *Data, customer *Customer) map[string]any { } username := "" if node.Protocol == "socks5-auth" { - username = NodeEmail(customer.ID) + // Item 21: the socks identity lives on the node now (fixed + // account), not per customer. + username = node.SocksUser } var publicKey any if key := realityPublicKeyOf(node); key != "" { diff --git a/panel-go/orchestrator.go b/panel-go/orchestrator.go index 488866d..dc52b22 100644 --- a/panel-go/orchestrator.go +++ b/panel-go/orchestrator.go @@ -345,21 +345,12 @@ func cancelQueuedDeletes(d *Data, nodeID, reason string) bool { } func RebuildNode(d *Data, node *Node, includeExit bool) error { + // Round-3 item 20: a node whose bindings drop to zero is rebuilt + // like any other (its inbound just ends up with no customer users); + // it is NOT auto-removed. The pre-item-20 auto-removal branch is + // gone; the AutoRemoving revival paths in assign/resume stay for + // records an older panel left in that state. clients := activeNodeClients(d, node) - if len(clients) == 0 { - if node.Status != "draft" && node.Status != "removing" { - QueueJob(d, node.ServerID, node.ID, "delete_resource", &JobPayload{ResourceID: node.ResourceID, Kind: "node"}) - if forwardNode(node) && node.ExitResourceID != "" { - QueueJob(d, xbDeref(node.ExitServerID), node.ID, "delete_resource", &JobPayload{ResourceID: node.ExitResourceID, Kind: "node-exit"}) - } - node.Status = "removing" - node.AutoRemoving = BoolPtr(true) - node.PendingDelete = false - node.Error = nil - } - node.UpdatedAt = NowISO() - return nil - } node.AutoRemoving = BoolPtr(false) resource, err := resourceForNode(d, node, clients) if err != nil { @@ -657,9 +648,12 @@ func ValidateNodeDeploy(d *Data, node *Node) error { return httpErr(409, "设备 %s 已停用", target.Name) } } - if len(activeNodeClients(d, node)) == 0 { - return httpErr(400, "请先给节点分配至少一个客户再部署") - } + // Round-3 item 20: no client-count gate. Nodes are built and deployed + // first and bound later (the 3x-ui model); bindings may go to zero + // and back without tearing the node down. A zero-client inbound + // simply has no users who can connect — including socks5-auth, + // whose inbound always carries its node-level fixed account + // (item 21), so it never degenerates into an open proxy. switch node.Protocol { case "vless-ws-tls", "hysteria2", "anytls": if server.TLSDomain == "" { @@ -807,9 +801,6 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions) } } else { clients := activeNodeClients(d, node) - if len(clients) == 0 { - continue - } if isEntry { resource, err := resourceForNode(d, node, clients) if err != nil { @@ -1217,9 +1208,6 @@ func (o *Orchestrator) ReconcileFromHeartbeat(serverID string, reportedResourceI if node.Status == "draft" || node.Status == "removing" { continue } - if len(activeNodeClients(d, node)) == 0 { - continue - } if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] { missing[node.ID] = true } diff --git a/panel-go/protocols.go b/panel-go/protocols.go index ffe47ac..9bc5aa8 100644 --- a/panel-go/protocols.go +++ b/panel-go/protocols.go @@ -236,7 +236,27 @@ func clientCredentials(nc *NodeClient) Credentials { return *nc.Credentials } +// EnsureSocksCredentials mints the node-level fixed account of a +// socks5-auth node (round-3 item 21) when either half is missing. The +// username deliberately avoids the "ng:" prefix: the agent maps sing-box +// user counters named "ng:" to per-customer usage samples, +// and the fixed account's traffic must stay node-level (the "inbound" +// sample), never attributed to a customer. The password is a base64 +// 32-byte random, the same strength as a customer's relay password. +func EnsureSocksCredentials(node *Node) { + if node.Protocol != "socks5-auth" { + return + } + if node.SocksUser == "" { + node.SocksUser = "ngs_" + hex.EncodeToString(xbRandBytes(5)) + } + if node.SocksPassword == "" { + node.SocksPassword = b64(32) + } +} + func EnsureNodeSecrets(node *Node) { + EnsureSocksCredentials(node) if node.WSPath == nil || *node.WSPath == "" { node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9))) } @@ -409,10 +429,12 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv } if protocol == "socks5-auth" { - users := make([]any, 0, len(nodeClients)) - for _, nc := range nodeClients { - users = append(users, map[string]any{"username": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword}) - } + // Round-3 item 21: the inbound carries exactly one user — the + // node's fixed account — regardless of bindings. Bindings only + // control subscription visibility; per-person accounting for + // relay chains happens at the entry side, not here. + EnsureSocksCredentials(node) + users := []any{map[string]any{"username": node.SocksUser, "password": node.SocksPassword}} return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil } diff --git a/panel-go/socksfixed_test.go b/panel-go/socksfixed_test.go new file mode 100644 index 0000000..40e8b40 --- /dev/null +++ b/panel-go/socksfixed_test.go @@ -0,0 +1,174 @@ +package main + +// Tests for round-3 items 20+21: build-before-bind (no client-count +// deploy gate, no auto-removal at zero bindings) and the socks5-auth +// node-level fixed account. + +import ( + "encoding/base64" + "os" + "path/filepath" + "strings" + "testing" +) + +func socksNodeData() *Data { + d := EmptyData() + d.Servers = []*Server{{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com", PortRangeStart: 20000, PortRangeEnd: 50000, + Status: "online", LastSeenAt: StrPtr(NowISO())}} + d.Customers = []*Customer{ + {ID: "cus_1", Name: "A", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001"}}, + {ID: "cus_2", Name: "B", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0002"}}, + } + return d +} + +func TestSocksInboundAlwaysFixedAccount(t *testing.T) { + node := &Node{ID: "nod_sk", Protocol: "socks5-auth", Port: 23462, NetworkMode: "ipv4", + SocksUser: "fixeduser", SocksPassword: "fixedpass"} + server := &Server{ID: "srv_1", PublicAddress: "entry.example.com"} + for _, nClients := range []int{0, 1, 2} { + clients := []*NodeClient{} + for i := 0; i < nClients; i++ { + clients = append(clients, &NodeClient{NodeID: node.ID, CustomerID: "cus_1", Status: "active", + Credentials: &Credentials{ClientID: "x", RelayPassword: "y", ShortID: "z"}}) + } + inbound, err := buildSingBoxInbound("t-in", node, clients, server, "") + if err != nil { + t.Fatalf("clients=%d: %v", nClients, err) + } + users, _ := inbound["users"].([]any) + if len(users) != 1 { + t.Fatalf("clients=%d: users = %d, want exactly the fixed account", nClients, len(users)) + } + u, _ := users[0].(map[string]any) + if u["username"] != "fixeduser" || u["password"] != "fixedpass" { + t.Fatalf("clients=%d: user = %v, want the fixed pair", nClients, u) + } + } + // A node without a stored pair gets one minted, never an ng: identity. + bare := &Node{ID: "nod_bare", Protocol: "socks5-auth", Port: 23463, NetworkMode: "ipv4"} + if _, err := buildSingBoxInbound("t-in", bare, nil, server, ""); err != nil { + t.Fatal(err) + } + if bare.SocksUser == "" || strings.HasPrefix(bare.SocksUser, "ng:") { + t.Fatalf("minted user %q must exist and avoid the ng: namespace", bare.SocksUser) + } + if raw, err := base64.StdEncoding.DecodeString(bare.SocksPassword); err != nil || len(raw) != 32 { + t.Fatalf("minted password must be a base64 32-byte key, got %q", bare.SocksPassword) + } +} + +func TestSocksMigrationIdempotent(t *testing.T) { + d := socksNodeData() + d.Nodes = []*Node{ + {ID: "nod_old", Protocol: "socks5-auth", Status: "active"}, + {ID: "nod_set", Protocol: "socks5-auth", Status: "active", SocksUser: "kept", SocksPassword: "keptpw"}, + {ID: "nod_vless", Protocol: "vless-reality", Status: "active"}, + } + if !MigrateSocksNodeCredentials(d) { + t.Fatal("first migration must report a change") + } + old := d.Nodes[0] + if old.SocksUser == "" || old.SocksPassword == "" { + t.Fatal("legacy socks node must receive a fixed pair") + } + if d.Nodes[1].SocksUser != "kept" || d.Nodes[1].SocksPassword != "keptpw" { + t.Fatal("an existing pair must be preserved") + } + if d.Nodes[2].SocksUser != "" { + t.Fatal("non-socks nodes must not gain an account") + } + user, password := old.SocksUser, old.SocksPassword + if MigrateSocksNodeCredentials(d) { + t.Fatal("second migration must be a no-op") + } + if old.SocksUser != user || old.SocksPassword != password { + t.Fatal("migration must be idempotent") + } +} + +func TestSocksMigrationRunsOnStoreLoad(t *testing.T) { + dir := t.TempDir() + file := filepath.Join(dir, "data.json") + d := socksNodeData() + seed := EmptyData() + seed.Servers = d.Servers + seed.Customers = d.Customers + seed.Nodes = []*Node{{ID: "nod_old", Protocol: "socks5-auth", Status: "active"}} + raw, err := marshalJSON(seed) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, append(raw, '\n'), 0o600); err != nil { + t.Fatal(err) + } + store := NewStore(file) + if err := store.Init(); err != nil { + t.Fatal(err) + } + node := store.Data.Nodes[0] + if node.SocksUser == "" || node.SocksPassword == "" { + t.Fatal("store load must migrate legacy socks nodes") + } + // And the migration is persisted, not just in memory. + reloaded := NewStore(file) + if err := reloaded.Init(); err != nil { + t.Fatal(err) + } + if reloaded.Data.Nodes[0].SocksUser != node.SocksUser { + t.Fatal("migrated pair must be persisted to disk") + } +} + +func TestZeroClientDeployNotGated(t *testing.T) { + d := socksNodeData() + vless := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23456, + Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}} + socks := &Node{ID: "nod_s", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23457} + d.Nodes = []*Node{vless, socks} + for _, node := range d.Nodes { + if err := ValidateNodeDeploy(d, node); err != nil { + t.Fatalf("%s zero-client deploy must be allowed: %v", node.Protocol, err) + } + } +} + +func TestZeroClientRebuildKeepsNode(t *testing.T) { + d := socksNodeData() + node := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "active", + ResourceID: "res_v", Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}} + d.Nodes = []*Node{node} + if err := RebuildNode(d, node, true); err != nil { + t.Fatal(err) + } + if node.Status == "removing" { + t.Fatal("zero-client rebuild must not auto-remove the node") + } + applies, deletes := 0, 0 + for _, job := range d.Jobs { + if job.NodeID == node.ID && job.Action == "apply_resource" { + applies++ + } + if job.NodeID == node.ID && job.Action == "delete_resource" { + deletes++ + } + } + if applies != 1 || deletes != 0 { + t.Fatalf("jobs: applies=%d deletes=%d, want 1/0", applies, deletes) + } +} + +func TestSocksSubscriptionCarriesFixedAccount(t *testing.T) { + d := socksNodeData() + node := &Node{ID: "nod_s", Name: "Socks", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct", + NetworkMode: "ipv4", Port: 23462, Status: "active", SocksUser: "fixeduser", SocksPassword: "fixedpass"} + d.Nodes = []*Node{node} + for _, customerID := range []string{"cus_1", "cus_2"} { + nc := &NodeClient{NodeID: node.ID, CustomerID: customerID, Status: "active"} + uri := NodeClientURI(d, nc) + if !strings.HasPrefix(uri, "socks5://fixeduser:fixedpass@entry.example.com:23462") { + t.Fatalf("%s: uri = %q, want the fixed account", customerID, uri) + } + } +} diff --git a/panel-go/store.go b/panel-go/store.go index 9deb053..0b1f695 100644 --- a/panel-go/store.go +++ b/panel-go/store.go @@ -134,7 +134,11 @@ func (s *Store) Init() error { if err := validateData(&data); err != nil { return err } - if MigrateCustomerCredentials(&data) { + migrated := MigrateCustomerCredentials(&data) + if MigrateSocksNodeCredentials(&data) { + migrated = true + } + if migrated { // Persist the converged credential model immediately, so a crash // before the next transaction cannot resurrect per-binding sets. if err := s.persist(&data); err != nil { @@ -145,6 +149,27 @@ func (s *Store) Init() error { return nil } +// MigrateSocksNodeCredentials gives every stored socks5-auth node its +// node-level fixed account (round-3 item 21): nodes created before the +// fixed-account model carried per-customer users derived at build time +// and stored no account of their own. A node missing either half gets a +// freshly minted pair (EnsureSocksCredentials only fills empty fields, +// so the migration is idempotent). Returns true when anything changed. +func MigrateSocksNodeCredentials(d *Data) bool { + changed := false + for _, node := range d.Nodes { + if node.Protocol != "socks5-auth" { + continue + } + user, password := node.SocksUser, node.SocksPassword + EnsureSocksCredentials(node) + if node.SocksUser != user || node.SocksPassword != password { + changed = true + } + } + return changed +} + // MigrateCustomerCredentials converges legacy per-binding credentials into // each customer's single set (round-2 item 2; the 3x-ui model of one // credential row per client). Deterministic rules: diff --git a/panel-go/subscriptions.go b/panel-go/subscriptions.go index 3ffad3a..c7cee17 100644 --- a/panel-go/subscriptions.go +++ b/panel-go/subscriptions.go @@ -112,15 +112,25 @@ func NodeClientURI(d *Data, nc *NodeClient) string { if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil { creds = *customer.Credentials } + // Round-3 item 21: a socks node's link carries the node's fixed + // account, identical for every bound customer; the binding only + // controls whether the node appears in the subscription at all. + socksUser := NodeEmail(nc.CustomerID) + relayPassword := creds.RelayPassword + if node.Protocol == "socks5-auth" { + EnsureSocksCredentials(node) + socksUser = node.SocksUser + relayPassword = node.SocksPassword + } uri, ok := BuildClientURI(BuildClientURIParams{ Protocol: node.Protocol, RelayServer: server, RelayPort: node.Port, ClientID: creds.ClientID, - RelayPassword: creds.RelayPassword, + RelayPassword: relayPassword, ShortID: creds.ShortID, WSPath: xbDeref(node.WSPath), - SocksUser: NodeEmail(nc.CustomerID), + SocksUser: socksUser, Reality: node.Reality, PublicKey: publicKey, Name: node.Name, diff --git a/panel-go/subscriptions_test.go b/panel-go/subscriptions_test.go index b902a77..d947b65 100644 --- a/panel-go/subscriptions_test.go +++ b/panel-go/subscriptions_test.go @@ -30,7 +30,13 @@ func goldenSubData() *Data { Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active", WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}}, {ID: "nod_sk", Name: "Socks", ServerID: "srv_entry", Protocol: "socks5-auth", - Topology: "direct", NetworkMode: "ipv4", Port: 23462, Status: "active", Artifacts: map[string]json.RawMessage{}}, + Topology: "direct", NetworkMode: "ipv4", Port: 23462, Status: "active", Artifacts: map[string]json.RawMessage{}, + // Round-3 item 21: the pair lives on the node now. The values + // deliberately equal the old per-binding derivation + // (ng:cus_1 + the customer's relay password) so every format + // body stays byte-identical — the change is only in where + // the pair comes from. + SocksUser: "ng:cus_1", SocksPassword: "FRYXGBkaGxwdHh8gISIjJA=="}, {ID: "nod_rv", Name: "RV-Plain", ServerID: "srv_v6", Protocol: "vless-reality", Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active", Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443}, diff --git a/panel-go/types.go b/panel-go/types.go index b9a39d0..24b45bf 100644 --- a/panel-go/types.go +++ b/panel-go/types.go @@ -735,6 +735,13 @@ type Node struct { AutoRemoving *bool `json:"autoRemoving,omitempty"` WSPath *string `json:"wsPath,omitempty"` SSServerKey *string `json:"ssServerKey,omitempty"` + // SocksUser/SocksPassword are the node-level fixed account of a + // socks5-auth inbound (round-3 item 21, the 3x-ui inbound model): + // the inbound's only user, carried by every subscriber's link for + // this node. Empty on legacy records until the store-load migration + // (MigrateSocksNodeCredentials) mints a pair. + SocksUser string `json:"socksUser,omitempty"` + SocksPassword string `json:"socksPassword,omitempty"` Reality *RealityConfig `json:"reality,omitempty"` ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"` UsageCounters *UsageCounters `json:"usageCounters"` diff --git a/public/app.js b/public/app.js index 2341f46..358fd12 100644 --- a/public/app.js +++ b/public/app.js @@ -904,6 +904,8 @@ function nodeForm(item = null) { + +
出口与转发传输
@@ -927,6 +929,8 @@ function syncNodeForm() { const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value); $$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality)); form.elements.realityServerName.required = reality; + const socks = form.elements.protocol.value === 'socks5-auth'; + $$('[data-socks-only]', form).forEach((element) => element.classList.toggle('hidden', !socks)); for (const [mode, port] of [['portMode', 'port'], ['exitPortMode', 'exitPort']]) { const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct); form.elements[port].disabled = !fixed; @@ -1104,7 +1108,7 @@ document.addEventListener('submit', async (event) => { closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers'); } else if (form.id === 'node-form') { const resourceId = data.get('resourceId'); - const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443), exitMode: form.dataset.exitMode || 'managed', exitAddress: data.get('exitAddress') || null }; + const payload = { name: data.get('name'), serverId: data.get('serverId'), protocol: data.get('protocol'), portMode: data.get('portMode'), port: data.get('port') || null, networkMode: data.get('networkMode'), topology: data.get('topology'), exitServerId: data.get('exitServerId') || null, exitProtocol: data.get('exitProtocol') || null, exitPortMode: data.get('exitPortMode') || null, exitPort: data.get('exitPort') || null, realityServerName: data.get('realityServerName'), realityDestPort: Number(data.get('realityDestPort') || 443), exitMode: form.dataset.exitMode || 'managed', exitAddress: data.get('exitAddress') || null, socksUser: data.get('socksUser') || null, socksPassword: data.get('socksPassword') || null }; if (payload.topology !== 'direct' && payload.exitMode === 'manual') { const creds = {}; if (data.get('exitClientId')) creds.clientId = data.get('exitClientId'); @@ -1114,7 +1118,7 @@ document.addEventListener('submit', async (event) => { payload.exitPortMode = 'fixed'; } await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) }); - closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains'); + closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,可直接部署,客户可随时增减绑定'); await load('chains'); } else if (form.id === 'account-form') { if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致'); await api('/api/account', { method: 'PATCH', body: JSON.stringify({ username: data.get('username'), currentPassword: data.get('currentPassword'), newPassword: data.get('newPassword') }) }); @@ -1173,6 +1177,7 @@ document.addEventListener('click', async (event) => { const action = button.dataset.action; const itemId = button.dataset.id; try { if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); } + else if (action === 'copy-field') { const input = button.form ? button.form.elements[button.dataset.field] : null; await copyText(input ? input.value : ''); toast('已复制到剪贴板'); } else if (action === 'add-server') serverForm(); else if (action === 'edit-server') serverForm(serverById(itemId)); else if (action === 'enroll-server') await enrollModal(itemId);