feat(panel): build-before-bind + socks5 fixed node account (items 20+21)

Item 20: drop the zero-client deploy gate in ValidateNodeDeploy for
all protocols, stop RebuildNode from auto-removing a node whose
bindings reach zero, and let server/heartbeat reconciliation rebuild
zero-client nodes like any other. Nodes are built and deployed first;
bindings can be added and removed freely.

Item 21: socks5-auth nodes carry one node-level fixed account
(node.socksUser/socksPassword, minted at creation or by the store-load
migration MigrateSocksNodeCredentials, idempotent). The sing-box
inbound's only user is that pair regardless of bindings; subscription
links for every bound customer carry the same pair. The generated
username avoids the ng: prefix so the agent never attributes the
account's traffic to a customer — it stays on the node's inbound
counters. Edit form shows both fields (copyable) for socks nodes;
NormalizeNode validates operator-supplied usernames.
This commit is contained in:
aaaamoooo
2026-10-21 10:40:58 +00:00
parent bb974e2ed7
commit 2fafd85d1f
9 changed files with 297 additions and 34 deletions
+27 -1
View File
@@ -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 == "" { if IsRealityProtocol(protocol) && next.RealityServerName == "" {
return nil, httpErr(400, "Reality SNI 不能为空") return nil, httpErr(400, "Reality SNI 不能为空")
} }
@@ -755,7 +779,9 @@ func customerCredentialsView(d *Data, customer *Customer) map[string]any {
} }
username := "" username := ""
if node.Protocol == "socks5-auth" { 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 var publicKey any
if key := realityPublicKeyOf(node); key != "" { if key := realityPublicKeyOf(node); key != "" {
+11 -23
View File
@@ -345,21 +345,12 @@ func cancelQueuedDeletes(d *Data, nodeID, reason string) bool {
} }
func RebuildNode(d *Data, node *Node, includeExit bool) error { 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) 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) node.AutoRemoving = BoolPtr(false)
resource, err := resourceForNode(d, node, clients) resource, err := resourceForNode(d, node, clients)
if err != nil { if err != nil {
@@ -657,9 +648,12 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
return httpErr(409, "设备 %s 已停用", target.Name) return httpErr(409, "设备 %s 已停用", target.Name)
} }
} }
if len(activeNodeClients(d, node)) == 0 { // Round-3 item 20: no client-count gate. Nodes are built and deployed
return httpErr(400, "请先给节点分配至少一个客户再部署") // 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 { switch node.Protocol {
case "vless-ws-tls", "hysteria2", "anytls": case "vless-ws-tls", "hysteria2", "anytls":
if server.TLSDomain == "" { if server.TLSDomain == "" {
@@ -807,9 +801,6 @@ func reconcileServerData(d *Data, serverID, actor string, opts ReconcileOptions)
} }
} else { } else {
clients := activeNodeClients(d, node) clients := activeNodeClients(d, node)
if len(clients) == 0 {
continue
}
if isEntry { if isEntry {
resource, err := resourceForNode(d, node, clients) resource, err := resourceForNode(d, node, clients)
if err != nil { if err != nil {
@@ -1217,9 +1208,6 @@ func (o *Orchestrator) ReconcileFromHeartbeat(serverID string, reportedResourceI
if node.Status == "draft" || node.Status == "removing" { if node.Status == "draft" || node.Status == "removing" {
continue continue
} }
if len(activeNodeClients(d, node)) == 0 {
continue
}
if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] { if node.ServerID == serverID && node.ResourceID != "" && !reported[node.ResourceID] {
missing[node.ID] = true missing[node.ID] = true
} }
+26 -4
View File
@@ -236,7 +236,27 @@ func clientCredentials(nc *NodeClient) Credentials {
return *nc.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:<customerId>" 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) { func EnsureNodeSecrets(node *Node) {
EnsureSocksCredentials(node)
if node.WSPath == nil || *node.WSPath == "" { if node.WSPath == nil || *node.WSPath == "" {
node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9))) node.WSPath = StrPtr("/" + hex.EncodeToString(xbRandBytes(9)))
} }
@@ -409,10 +429,12 @@ func buildSingBoxInbound(tag string, node *Node, nodeClients []*NodeClient, serv
} }
if protocol == "socks5-auth" { if protocol == "socks5-auth" {
users := make([]any, 0, len(nodeClients)) // Round-3 item 21: the inbound carries exactly one user — the
for _, nc := range nodeClients { // node's fixed account — regardless of bindings. Bindings only
users = append(users, map[string]any{"username": NodeEmail(nc.CustomerID), "password": clientCredentials(nc).RelayPassword}) // 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 return map[string]any{"type": "socks", "tag": tag, "listen": listen, "listen_port": port, "users": users}, nil
} }
+174
View File
@@ -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)
}
}
}
+26 -1
View File
@@ -134,7 +134,11 @@ func (s *Store) Init() error {
if err := validateData(&data); err != nil { if err := validateData(&data); err != nil {
return err return err
} }
if MigrateCustomerCredentials(&data) { migrated := MigrateCustomerCredentials(&data)
if MigrateSocksNodeCredentials(&data) {
migrated = true
}
if migrated {
// Persist the converged credential model immediately, so a crash // Persist the converged credential model immediately, so a crash
// before the next transaction cannot resurrect per-binding sets. // before the next transaction cannot resurrect per-binding sets.
if err := s.persist(&data); err != nil { if err := s.persist(&data); err != nil {
@@ -145,6 +149,27 @@ func (s *Store) Init() error {
return nil 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 // MigrateCustomerCredentials converges legacy per-binding credentials into
// each customer's single set (round-2 item 2; the 3x-ui model of one // each customer's single set (round-2 item 2; the 3x-ui model of one
// credential row per client). Deterministic rules: // credential row per client). Deterministic rules:
+12 -2
View File
@@ -112,15 +112,25 @@ func NodeClientURI(d *Data, nc *NodeClient) string {
if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil { if customer := findCustomer(d, nc.CustomerID); customer != nil && customer.Credentials != nil {
creds = *customer.Credentials 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{ uri, ok := BuildClientURI(BuildClientURIParams{
Protocol: node.Protocol, Protocol: node.Protocol,
RelayServer: server, RelayServer: server,
RelayPort: node.Port, RelayPort: node.Port,
ClientID: creds.ClientID, ClientID: creds.ClientID,
RelayPassword: creds.RelayPassword, RelayPassword: relayPassword,
ShortID: creds.ShortID, ShortID: creds.ShortID,
WSPath: xbDeref(node.WSPath), WSPath: xbDeref(node.WSPath),
SocksUser: NodeEmail(nc.CustomerID), SocksUser: socksUser,
Reality: node.Reality, Reality: node.Reality,
PublicKey: publicKey, PublicKey: publicKey,
Name: node.Name, Name: node.Name,
+7 -1
View File
@@ -30,7 +30,13 @@ func goldenSubData() *Data {
Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active", Topology: "direct", NetworkMode: "ipv4", Port: 23461, Status: "active",
WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}}, WSPath: StrPtr("/wspath123"), Artifacts: map[string]json.RawMessage{}},
{ID: "nod_sk", Name: "Socks", ServerID: "srv_entry", Protocol: "socks5-auth", {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", {ID: "nod_rv", Name: "RV-Plain", ServerID: "srv_v6", Protocol: "vless-reality",
Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active", Topology: "direct", NetworkMode: "ipv4", Port: 23463, Status: "active",
Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443}, Reality: &RealityConfig{KeyID: "reality-nod_rv", ServerName: "www.apple.com", DestPort: 443},
+7
View File
@@ -735,6 +735,13 @@ type Node struct {
AutoRemoving *bool `json:"autoRemoving,omitempty"` AutoRemoving *bool `json:"autoRemoving,omitempty"`
WSPath *string `json:"wsPath,omitempty"` WSPath *string `json:"wsPath,omitempty"`
SSServerKey *string `json:"ssServerKey,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"` Reality *RealityConfig `json:"reality,omitempty"`
ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"` ExitCredentials *ExitCredentials `json:"exitCredentials,omitempty"`
UsageCounters *UsageCounters `json:"usageCounters"` UsageCounters *UsageCounters `json:"usageCounters"`
+7 -2
View File
@@ -904,6 +904,8 @@ function nodeForm(item = null) {
<label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label> <label>入口端口<div class="inline-fields"><select name="portMode" data-node-sync><option value="random"${selected('random', node.portMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.portMode)}>固定端口</option></select><input name="port" type="number" value="${esc(node.port || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select></label> <label data-reality-only>Reality 目标预设<select name="realityPreset" data-reality-preset>${state.realityPresets.map((entry) => `<option value="${esc(entry.id)}" data-sni="${esc(entry.serverName)}" data-port="${entry.destPort}"${selected(entry.id, preset.id)}>${esc(entry.label)} · ${esc(entry.serverName)}</option>`).join('')}<option value="custom"${selected('custom', preset.id)}>自定义</option></select></label>
<label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label> <label data-reality-only>Reality SNI / 目标端口<div class="inline-fields"><input name="realityServerName" value="${esc(node.realityServerName || 'www.tesla.com')}" placeholder="www.tesla.com"><input name="realityDestPort" type="number" value="${Number(node.realityDestPort || 443)}" min="1" max="65535"></div></label>
<label data-socks-only>SOCKS5 固定用户名<div class="inline-fields"><input name="socksUser" value="${esc(node.socksUser || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksUser">${ICONS.copy}</button></div></label>
<label data-socks-only>SOCKS5 固定密码<div class="inline-fields"><input name="socksPassword" value="${esc(node.socksPassword || '')}" placeholder="留空自动生成"><button type="button" class="icon-btn" data-tip="复制" data-action="copy-field" data-field="socksPassword">${ICONS.copy}</button></div></label>
<label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward', node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct', node.topology)}>单机直连(无需出口)</option></select></label> <label>拓扑<select name="topology" data-node-sync><option value="forward"${selected('forward', node.topology || 'forward')}>转发(入口 → 出口)</option><option value="direct"${selected('direct', node.topology)}>单机直连(无需出口)</option></select></label>
<div class="section-title" data-forward-only>出口与转发传输</div> <div class="section-title" data-forward-only>出口与转发传输</div>
<div class="wide" data-forward-only><div class="segctl" id="exit-mode-seg"><button type="button" data-action="exit-mode" data-mode="managed">受管设备</button><button type="button" data-action="exit-mode" data-mode="manual">手动落地端点</button></div></div> <div class="wide" data-forward-only><div class="segctl" id="exit-mode-seg"><button type="button" data-action="exit-mode" data-mode="managed">受管设备</button><button type="button" data-action="exit-mode" data-mode="manual">手动落地端点</button></div></div>
@@ -927,6 +929,8 @@ function syncNodeForm() {
const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value); const reality = ['vless-reality-vision', 'vless-reality'].includes(form.elements.protocol.value);
$$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality)); $$('[data-reality-only]', form).forEach((element) => element.classList.toggle('hidden', !reality));
form.elements.realityServerName.required = 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']]) { for (const [mode, port] of [['portMode', 'port'], ['exitPortMode', 'exitPort']]) {
const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct); const fixed = form.elements[mode].value === 'fixed' && (mode === 'portMode' || !direct);
form.elements[port].disabled = !fixed; form.elements[port].disabled = !fixed;
@@ -1104,7 +1108,7 @@ document.addEventListener('submit', async (event) => {
closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers'); closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
} else if (form.id === 'node-form') { } else if (form.id === 'node-form') {
const resourceId = data.get('resourceId'); 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') { if (payload.topology !== 'direct' && payload.exitMode === 'manual') {
const creds = {}; const creds = {};
if (data.get('exitClientId')) creds.clientId = data.get('exitClientId'); if (data.get('exitClientId')) creds.clientId = data.get('exitClientId');
@@ -1114,7 +1118,7 @@ document.addEventListener('submit', async (event) => {
payload.exitPortMode = 'fixed'; payload.exitPortMode = 'fixed';
} }
await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) }); 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') { } else if (form.id === 'account-form') {
if (data.get('newPassword') !== data.get('confirmPassword')) throw new Error('两次输入的新密码不一致'); 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') }) }); 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; const action = button.dataset.action; const itemId = button.dataset.id;
try { try {
if (action === 'copy-text') { await copyText(button.dataset.value); toast('已复制到剪贴板'); } 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 === 'add-server') serverForm();
else if (action === 'edit-server') serverForm(serverById(itemId)); else if (action === 'edit-server') serverForm(serverById(itemId));
else if (action === 'enroll-server') await enrollModal(itemId); else if (action === 'enroll-server') await enrollModal(itemId);