From 0037116edcb2bbd06d8ac42acb596c2220bdeb3b Mon Sep 17 00:00:00 2001 From: aaaamoooo Date: Tue, 20 Oct 2026 21:36:20 +0000 Subject: [PATCH] feat: manual landing endpoints for forward nodes (item 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A forward node's exit target is now a choice: a managed panel server (existing behavior) or a manual landing endpoint — address + port + protocol (VLESS / SS2022 / SOCKS5) + operator-supplied credentials, including third-party nodes the panel does not run. The panel only builds the entry-side sing-box outbound and route rule; there is no exit resource, no exit job, and the node is exit-ready by definition. - model: Node.ExitMode/ExitAddress; manual exits force fixed port mode and skip the exit-server port table entirely - validation: address/port required; per-protocol credential presence; SS2022 passwords must be base64 key material of the method's key length (one PSK segment or serverKey:userKey) — the exact form sing-box accepts, rejected up front instead of failing at deploy - orchestration: resource building, deploy validation and rebuilds all branch on manualExit; switching managed->manual queues removal of the old exit resource, manual->managed mints a fresh exit resource id (deploy validation previously dereferenced the nil exit server — regression test locks the fix) - UI: exit target segmented control, link paste box that parses vless:// / ss:// / socks5:// links into the form, per-protocol credential fields (blank keeps the stored value on edit) - golden: new manualCreate/manualAssign/manualDeploy/manualJobs/ manualNodes sections — exactly one entry-side job whose outbound targets the manual address; all prior sections unchanged - the generated combined sing-box config passes the real sing-box 1.13.21 check --- panel-go/golden/golden.json | 489 +++++++++++++++++++++++++++++++++++- panel-go/golden/golden.mjs | 17 ++ panel-go/handlers.go | 137 +++++++++- panel-go/manualexit_test.go | 141 +++++++++++ panel-go/orchestrator.go | 25 +- panel-go/protocols.go | 13 +- panel-go/types.go | 21 +- public/app.js | 96 ++++++- public/styles.css | 3 + 9 files changed, 911 insertions(+), 31 deletions(-) create mode 100644 panel-go/manualexit_test.go diff --git a/panel-go/golden/golden.json b/panel-go/golden/golden.json index 1210bf3..8346b00 100644 --- a/panel-go/golden/golden.json +++ b/panel-go/golden/golden.json @@ -1,6 +1,6 @@ { "meta": { - "capturedAt": "2026-10-20T21:20:39.194Z", + "capturedAt": "2026-10-20T21:35:58.550Z", "note": "Go panel golden baseline; see README.md in this directory." }, "sections": { @@ -2835,15 +2835,15 @@ } ], "keyStore": { - "reality-#3": { - "privateKey": "#1", - "publicKey": "#2", - "updatedAt": "" - }, "reality-#2": { "privateKey": "#2", "publicKey": "#1", "updatedAt": "" + }, + "reality-#3": { + "privateKey": "#1", + "publicKey": "#2", + "updatedAt": "" } }, "nodeClients": [ @@ -3608,6 +3608,483 @@ }, "status": 401 } + }, + "manualCreate": { + "body": { + "node": { + "artifacts": {}, + "createdAt": "", + "entryReady": false, + "error": null, + "exitAddress": "203.0.113.9", + "exitMode": "manual", + "exitPort": "#6", + "exitPortMode": "fixed", + "exitProtocol": "shadowsocks-2022-aes128", + "exitReady": true, + "exitResourceId": "", + "exitServerId": null, + "id": "#4", + "lastUsageAt": null, + "meteredTraffic": null, + "name": "N4-Manual", + "networkMode": "ipv4", + "pendingDelete": false, + "port": "#7", + "portMode": "fixed", + "protocol": "vless-reality-vision", + "reality": { + "destPort": 443, + "keyId": "reality-#4", + "serverName": "www.amd.com" + }, + "realityDestPort": 443, + "realityServerName": "www.amd.com", + "resourceId": "#7", + "retiredUsageEpochs": [], + "serverId": "#1", + "sortIndex": 3, + "status": "draft", + "topology": "forward", + "updatedAt": "", + "usageCounters": null, + "wsPath": "#13" + } + }, + "status": 201 + }, + "manualAssign": { + "body": { + "nodeClient": { + "createdAt": "", + "customerId": "#3", + "id": "#5", + "nodeId": "#4", + "status": "active", + "updatedAt": "" + } + }, + "status": 200 + }, + "manualDeploy": { + "body": { + "ok": true + }, + "status": 202 + }, + "manualJobs": [ + { + "complete": { + "job": { + "action": "apply_resource", + "attempts": 1, + "completedAt": "", + "createdAt": "", + "error": null, + "id": "#7", + "leaseUntil": null, + "nodeId": "#4", + "payload": { + "keyMaterial": { + "reality-#4": { + "privateKey": "#3", + "publicKey": "#3" + } + }, + "kind": "node", + "resource": { + "engine": "sing-box", + "id": "#7", + "inbounds": [ + { + "listen": "", + "listen_port": "#7", + "tag": "ng-#4-in", + "tls": { + "enabled": true, + "reality": { + "enabled": true, + "handshake": { + "server": "www.amd.com", + "server_port": "#5" + }, + "private_key": "${REALITY_PRIVATE:reality-#4}", + "short_id": [ + "#8" + ] + }, + "server_name": "www.amd.com" + }, + "type": "vless", + "users": [ + { + "flow": "xtls-rprx-vision", + "name": "ng:#3", + "uuid": "#3" + } + ] + } + ], + "meta": { + "kind": "node", + "metricsTag": "ng-#4-in", + "nodeId": "#4", + "protocol": "vless-reality-vision" + }, + "outbounds": [ + { + "method": "2022-blake3-aes-128-gcm", + "password": "#14", + "server": "203.0.113.9", + "server_port": "#6", + "tag": "ng-#4-exit", + "type": "shadowsocks" + } + ], + "routingRules": [ + { + "action": "route", + "inbound": [ + "ng-#4-in" + ], + "outbound": "ng-#4-exit" + } + ] + } + }, + "serverId": "#1", + "startedAt": "", + "status": "completed", + "updatedAt": "" + } + }, + "job": { + "action": "apply_resource", + "attempts": 1, + "createdAt": "", + "error": null, + "id": "#7", + "leaseUntil": "", + "nodeId": "#4", + "payload": { + "keyMaterial": { + "reality-#4": { + "privateKey": "#3", + "publicKey": "#3" + } + }, + "kind": "node", + "resource": { + "engine": "sing-box", + "id": "#7", + "inbounds": [ + { + "listen": "", + "listen_port": "#7", + "tag": "ng-#4-in", + "tls": { + "enabled": true, + "reality": { + "enabled": true, + "handshake": { + "server": "www.amd.com", + "server_port": "#5" + }, + "private_key": "${REALITY_PRIVATE:reality-#4}", + "short_id": [ + "#8" + ] + }, + "server_name": "www.amd.com" + }, + "type": "vless", + "users": [ + { + "flow": "xtls-rprx-vision", + "name": "ng:#3", + "uuid": "#3" + } + ] + } + ], + "meta": { + "kind": "node", + "metricsTag": "ng-#4-in", + "nodeId": "#4", + "protocol": "vless-reality-vision" + }, + "outbounds": [ + { + "method": "2022-blake3-aes-128-gcm", + "password": "#14", + "server": "203.0.113.9", + "server_port": "#6", + "tag": "ng-#4-exit", + "type": "shadowsocks" + } + ], + "routingRules": [ + { + "action": "route", + "inbound": [ + "ng-#4-in" + ], + "outbound": "ng-#4-exit" + } + ] + } + }, + "serverId": "#1", + "startedAt": "", + "status": "running", + "updatedAt": "" + } + } + ], + "manualNodes": { + "body": { + "nodeClients": [ + { + "clientUri": "vless://#1@entry.example.com:#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#1&sid=#2&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed", + "createdAt": "", + "customerId": "#1", + "id": "#1", + "nodeId": "#2", + "status": "active", + "updatedAt": "" + }, + { + "clientUri": "vless://#2@entry.example.com:#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#1&sid=#4&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed", + "createdAt": "", + "customerId": "#2", + "id": "#2", + "nodeId": "#2", + "status": "active", + "updatedAt": "" + }, + { + "clientUri": "ss://#1@entry.example.com:#3#N2", + "createdAt": "", + "customerId": "#1", + "id": "#3", + "nodeId": "#1", + "status": "active", + "updatedAt": "" + }, + { + "clientUri": "vless://#3@s3.example.com:#4?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#2&sid=#8&type=tcp&headerType=none#N3", + "createdAt": "", + "customerId": "#3", + "id": "#4", + "nodeId": "#3", + "status": "active", + "updatedAt": "" + }, + { + "clientUri": "vless://#3@entry.example.com:#7?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=#3&sid=#8&type=tcp&headerType=none&flow=xtls-rprx-vision#N4-Manual", + "createdAt": "", + "customerId": "#3", + "id": "#5", + "nodeId": "#4", + "status": "active", + "updatedAt": "" + } + ], + "nodes": [ + { + "artifacts": { + "realityPublicKey": "#1" + }, + "autoRemoving": false, + "clientCount": 2, + "createdAt": "", + "entryReady": true, + "error": null, + "exitPort": "#1", + "exitPortMode": "random", + "exitProtocol": "shadowsocks-2022-aes256", + "exitReady": true, + "exitResourceId": "#1", + "exitServerId": "#2", + "id": "#2", + "lastActiveAt": "", + "lastSampleAt": "", + "lastUsageAt": "", + "meteredTraffic": { + "downlink": 14000, + "uplink": 12000 + }, + "name": "N1-renamed", + "networkMode": "ipv4", + "onlineCount": 1, + "onlineCustomerIds": [ + "#1" + ], + "pendingDelete": false, + "port": "#2", + "portMode": "random", + "protocol": "vless-reality-vision", + "reality": { + "destPort": 443, + "keyId": "reality-#2", + "serverName": "www.amd.com" + }, + "realityDestPort": 443, + "realityServerName": "www.amd.com", + "resourceId": "#2", + "retiredUsageEpochs": [ + "diff-epoch-1" + ], + "serverId": "#1", + "sortIndex": 0, + "speedAt": "", + "speedDownBps": "", + "speedUpBps": "", + "status": "active", + "topology": "forward", + "updatedAt": "", + "usageCounters": { + "downlink": 9000, + "epoch": "golden-epoch-1", + "uplink": 8000 + }, + "wsPath": "#5" + }, + { + "artifacts": {}, + "autoRemoving": false, + "clientCount": 1, + "createdAt": "", + "entryReady": true, + "error": null, + "exitPort": null, + "exitPortMode": null, + "exitProtocol": null, + "exitReady": false, + "exitResourceId": "#3", + "exitServerId": null, + "id": "#1", + "lastUsageAt": null, + "meteredTraffic": null, + "name": "N2", + "networkMode": "ipv4", + "onlineCount": 1, + "onlineCustomerIds": [ + "#1" + ], + "pendingDelete": false, + "port": "#3", + "portMode": "random", + "protocol": "shadowsocks-2022-aes128", + "realityDestPort": 443, + "realityServerName": "www.tesla.com", + "resourceId": "#4", + "retiredUsageEpochs": [], + "serverId": "#1", + "sortIndex": 1, + "speedDownBps": 0, + "speedUpBps": 0, + "status": "active", + "topology": "direct", + "updatedAt": "", + "usageCounters": null, + "wsPath": "#6" + }, + { + "artifacts": { + "realityPublicKey": "#2" + }, + "autoRemoving": false, + "clientCount": 1, + "createdAt": "", + "entryReady": true, + "error": null, + "exitPort": null, + "exitPortMode": null, + "exitProtocol": null, + "exitReady": false, + "exitResourceId": "#5", + "exitServerId": null, + "id": "#3", + "lastUsageAt": null, + "meteredTraffic": null, + "name": "N3", + "networkMode": "ipv4", + "onlineCount": 0, + "onlineCustomerIds": [], + "pendingDelete": false, + "port": "#4", + "portMode": "random", + "protocol": "vless-reality", + "reality": { + "destPort": 443, + "keyId": "reality-#3", + "serverName": "www.amd.com" + }, + "realityDestPort": 443, + "realityServerName": "www.amd.com", + "resourceId": "#6", + "retiredUsageEpochs": [], + "serverId": "#3", + "sortIndex": 2, + "speedDownBps": 0, + "speedUpBps": 0, + "status": "active", + "topology": "direct", + "updatedAt": "", + "usageCounters": null, + "wsPath": "#9" + }, + { + "artifacts": { + "realityPublicKey": "#3" + }, + "autoRemoving": false, + "clientCount": 1, + "createdAt": "", + "entryReady": true, + "error": null, + "exitAddress": "203.0.113.9", + "exitMode": "manual", + "exitPort": "#6", + "exitPortMode": "fixed", + "exitProtocol": "shadowsocks-2022-aes128", + "exitReady": true, + "exitResourceId": "", + "exitServerId": null, + "id": "#4", + "lastUsageAt": null, + "meteredTraffic": null, + "name": "N4-Manual", + "networkMode": "ipv4", + "onlineCount": 0, + "onlineCustomerIds": [], + "pendingDelete": false, + "port": "#7", + "portMode": "fixed", + "protocol": "vless-reality-vision", + "reality": { + "destPort": 443, + "keyId": "reality-#4", + "serverName": "www.amd.com" + }, + "realityDestPort": 443, + "realityServerName": "www.amd.com", + "resourceId": "#7", + "retiredUsageEpochs": [], + "serverId": "#1", + "sortIndex": 3, + "speedDownBps": 0, + "speedUpBps": 0, + "status": "active", + "topology": "forward", + "updatedAt": "", + "usageCounters": null, + "wsPath": "#13" + } + ] + }, + "status": 200 } } } diff --git a/panel-go/golden/golden.mjs b/panel-go/golden/golden.mjs index 6a38769..31b8b63 100644 --- a/panel-go/golden/golden.mjs +++ b/panel-go/golden/golden.mjs @@ -238,6 +238,23 @@ async function runScript(bin) { })(), }); + // Round-2 item 8: manual landing endpoint. Creation is exit-ready with + // no exit server; deploying queues only the entry-side job, whose + // outbound targets the manual address with the operator's credentials. + const manualCreate = await client.admin('POST', '/api/nodes', { + name: 'N4-Manual', serverId: ids.entryServer, protocol: 'vless-reality-vision', topology: 'forward', + exitMode: 'manual', exitAddress: '203.0.113.9', exitProtocol: 'shadowsocks-2022-aes128', + exitPort: 23464, exitPortMode: 'fixed', exitCredentials: { password: 'FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==' }, + portMode: 'fixed', port: 23466, networkMode: 'ipv4', realityServerName: 'www.amd.com', realityDestPort: 443, + }); + cap('manualCreate', view(manualCreate)); + const manualNodeId = manualCreate.json.node.id; + cap('manualAssign', view(await client.admin('POST', `/api/nodes/${manualNodeId}/assign`, { customerId: ids.carol }))); + cap('manualDeploy', view(await client.admin('POST', `/api/nodes/${manualNodeId}/deploy`, {}))); + const drainedManual = await drainAgentJobs(client, agentKeys.entry); + cap('manualJobs', drainedManual.map(({ job, complete }) => ({ job, complete }))); + cap('manualNodes', view(await client.admin('GET', '/api/nodes'))); + return sections; } finally { await stopPanel(proc); diff --git a/panel-go/handlers.go b/panel-go/handlers.go index 7129115..4e72ffa 100644 --- a/panel-go/handlers.go +++ b/panel-go/handlers.go @@ -7,6 +7,7 @@ package main import ( "crypto/subtle" + "encoding/base64" "encoding/json" "fmt" "math" @@ -208,6 +209,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error topology = "forward" } direct := topology == "direct" + // Exit target selection (round-2 item 8): "manual" addresses an + // unmanaged landing endpoint; anything else keeps the managed server. + exitMode := next.ExitMode + if _, ok := body["exitMode"]; ok { + if s, _ := body["exitMode"].(string); s == "manual" { + exitMode = "manual" + } else { + exitMode = "managed" + } + } + manual := !direct && exitMode == "manual" protocol := next.Protocol if _, ok := body["protocol"]; ok { @@ -232,7 +244,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error return nil, httpErr(404, "服务器不存在") } var exitServerID *string - if direct { + if direct || manual { exitServerID = nil } else if _, ok := body["exitServerId"]; ok { id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120) @@ -243,7 +255,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error } else { exitServerID = next.ExitServerID } - if !direct { + if !direct && !manual { found := false for _, item := range data.Servers { if item.ID == strVal(exitServerID) { @@ -266,6 +278,26 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error next.Protocol = protocol next.Topology = topology next.ExitServerID = exitServerID + next.ExitMode = "" + next.ExitAddress = "" + if manual { + next.ExitMode = "manual" + address := "" + if current != nil { + address = current.ExitAddress + } + if _, ok := body["exitAddress"]; ok { + s, err := RequiredText(body["exitAddress"], "落地端点地址", 255) + if err != nil { + return nil, err + } + address = s + } + if address == "" || strings.ContainsAny(address, " \t\r\n/") || strings.Contains(address, "://") { + return nil, httpErr(400, "手动落地端点地址无效") + } + next.ExitAddress = address + } if direct { next.ExitProtocol = nil } else if _, ok := body["exitProtocol"]; ok { @@ -373,7 +405,25 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error } next.Port = port } - if !direct { + if manual { + // A manual endpoint's port belongs to the remote service: it is + // always explicit (never rolled, never checked against a panel + // server's port table). + fixed := "fixed" + next.ExitPortMode = &fixed + port := 0 + if v, ok := body["exitPort"]; ok && !jsFalsy(v) { + if f, valid := anyToJSNumber(v); valid { + port = int(f) + } + } else if current != nil && current.ExitPort != nil { + port = *current.ExitPort + } + if port < 1 || port > 65535 { + return nil, httpErr(400, "手动落地端点端口无效") + } + next.ExitPort = &port + } else if !direct { exitPortMode := strVal(next.ExitPortMode) if _, ok := body["exitPortMode"]; ok { if s, _ := body["exitPortMode"].(string); s == "fixed" { @@ -461,6 +511,64 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error return nil, herr } } + if manual { + // Manual endpoint credentials describe a third-party service, so + // they come from the operator (form fields or a pasted share link), + // never generated. Fields merge over the stored set so an edit that + // leaves a field blank keeps the previous value. + if rawCreds, ok := body["exitCredentials"].(map[string]any); ok { + creds := ExitCredentials{} + if next.ExitCredentials != nil { + creds = *next.ExitCredentials + } + if v, ok := rawCreds["clientId"]; ok { + creds.ClientID = CleanText(v, 128) + } + if v, ok := rawCreds["password"]; ok { + creds.Password = CleanText(v, 512) + } + if v, ok := rawCreds["socksUser"]; ok { + creds.SocksUser = CleanText(v, 128) + } + next.ExitCredentials = &creds + } + creds := next.ExitCredentials + exitProtocol := strVal(next.ExitProtocol) + switch { + case creds == nil: + return nil, httpErr(400, "手动落地端点缺少凭据") + case exitProtocol == "vless-tcp" && creds.ClientID == "": + return nil, httpErr(400, "手动落地端点缺少 VLESS 用户 ID") + case exitProtocol == "socks5-auth" && (creds.SocksUser == "" || creds.Password == ""): + return nil, httpErr(400, "手动落地端点缺少 SOCKS5 用户名或密码") + default: + if cipher, isSS := ShadowsocksMethods[exitProtocol]; isSS { + if creds.Password == "" { + return nil, httpErr(400, "手动落地端点缺少 SS 密码") + } + // SS2022 keys are base64 key material (one PSK segment, or + // serverKey:userKey); sing-box rejects anything else when + // the config loads, so reject it here with a clear error. + if strings.HasPrefix(cipher.Method, "2022-blake3-") { + segments := strings.Split(creds.Password, ":") + if len(segments) > 2 { + return nil, httpErr(400, "手动落地端点 SS 密码格式无效") + } + for _, segment := range segments { + raw, err := base64.StdEncoding.DecodeString(segment) + if err != nil { + if raw, err = base64.RawStdEncoding.DecodeString(segment); err != nil { + return nil, httpErr(400, "手动落地端点 SS 密码必须是 base64 密钥") + } + } + if len(raw) != cipher.KeyBytes { + return nil, httpErr(400, "手动落地端点 SS 密钥长度与加密方法不符") + } + } + } + } + } + } if IsRealityProtocol(protocol) && next.RealityServerName == "" { return nil, httpErr(400, "Reality SNI 不能为空") } @@ -2412,6 +2520,12 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st next.ExitReady = false next.ResourceID = NewID("res") next.ExitResourceID = NewID("res") + if manualExit(next) { + // Nothing is deployed on a manual landing endpoint, so the + // exit side is ready by definition and owns no resource. + next.ExitReady = true + next.ExitResourceID = "" + } next.Artifacts = map[string]json.RawMessage{} next.Error = nil next.PendingDelete = false @@ -2454,6 +2568,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st } oldServerID := item.ServerID oldExitServerID := strVal(item.ExitServerID) + oldExitResourceID := item.ExitResourceID oldForward := forwardNode(item) next, err := NormalizeNode(body, item, d) if err != nil { @@ -2465,8 +2580,17 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"}) item.EntryReady = false } - if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) { - QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"}) + if oldForward && oldExitResourceID != "" && (!forwardNode(item) || manualExit(item) || strVal(item.ExitServerID) != oldExitServerID) { + QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: oldExitResourceID, Kind: "node-exit"}) + item.ExitReady = false + } + if manualExit(item) { + item.ExitReady = true + item.ExitResourceID = "" + } else if forwardNode(item) && item.ExitResourceID == "" { + // Switching from a manual endpoint back to a managed exit + // server needs a fresh exit resource identity. + item.ExitResourceID = NewID("res") item.ExitReady = false } if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" { @@ -2474,6 +2598,9 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st return nil, err } } + if manualExit(item) { + item.ExitReady = true + } item.UpdatedAt = NowISO() Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name}) return cloneJSONValue(item), nil diff --git a/panel-go/manualexit_test.go b/panel-go/manualexit_test.go new file mode 100644 index 0000000..d71cc30 --- /dev/null +++ b/panel-go/manualexit_test.go @@ -0,0 +1,141 @@ +package main + +// Tests for manual landing endpoints (round-2 item 8): NormalizeNode +// validation, entry-side outbound generation, and the no-exit-job rule. + +import ( + "encoding/json" + "testing" +) + +func manualData() *Data { + return &Data{ + Servers: []*Server{{ID: "srv_entry", Name: "Entry", PublicAddress: "entry.example.com"}}, + } +} + +func manualBody() map[string]any { + return map[string]any{ + "name": "Manual", "serverId": "srv_entry", "protocol": "vless-reality-vision", + "topology": "forward", "exitMode": "manual", "exitAddress": "203.0.113.9", + "exitProtocol": "shadowsocks-2022-aes128", "exitPort": json.Number("23464"), + "exitCredentials": map[string]any{"password": "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA=="}, + "portMode": "fixed", "port": json.Number("23465"), + } +} + +func TestNormalizeManualExit(t *testing.T) { + node, err := NormalizeNode(manualBody(), nil, manualData()) + if err != nil { + t.Fatalf("normalize: %v", err) + } + if node.ExitMode != "manual" || node.ExitAddress != "203.0.113.9" { + t.Fatalf("mode/address not stored: %q %q", node.ExitMode, node.ExitAddress) + } + if node.ExitServerID != nil { + t.Fatalf("manual node must not reference an exit server") + } + if node.ExitPort == nil || *node.ExitPort != 23464 { + t.Fatalf("manual port not kept: %v", node.ExitPort) + } + if node.ExitPortMode == nil || *node.ExitPortMode != "fixed" { + t.Fatalf("manual port mode must be fixed") + } + if node.ExitCredentials == nil || node.ExitCredentials.Password != "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==" { + t.Fatalf("manual credentials not stored") + } +} + +func TestNormalizeManualExitValidation(t *testing.T) { + cases := []struct { + name string + mutate func(map[string]any) + }{ + {"missing address", func(b map[string]any) { delete(b, "exitAddress") }}, + {"address with scheme", func(b map[string]any) { b["exitAddress"] = "https://x.example.com" }}, + {"missing port", func(b map[string]any) { delete(b, "exitPort") }}, + {"missing ss password", func(b map[string]any) { b["exitCredentials"] = map[string]any{} }}, + {"ss2022 password not base64", func(b map[string]any) { + b["exitCredentials"] = map[string]any{"password": "not-a-base64-key"} + }}, + {"ss2022 key wrong length", func(b map[string]any) { + // A 16-byte key against the aes256 method (needs 32). + b["exitCredentials"] = map[string]any{"password": "AAAAAAAAAAAAAAAAAAAAAA=="} + b["exitProtocol"] = "shadowsocks-2022-aes256" + }}, + {"vless without uuid", func(b map[string]any) { + b["exitProtocol"] = "vless-tcp" + b["exitCredentials"] = map[string]any{"password": "x"} + }}, + {"socks without user", func(b map[string]any) { + b["exitProtocol"] = "socks5-auth" + b["exitCredentials"] = map[string]any{"password": "x"} + }}, + } + for _, tc := range cases { + body := manualBody() + tc.mutate(body) + if _, err := NormalizeNode(body, nil, manualData()); err == nil { + t.Fatalf("%s: expected an error", tc.name) + } + } +} + +func TestManualExitResourceAndJobs(t *testing.T) { + node, err := NormalizeNode(manualBody(), nil, manualData()) + if err != nil { + t.Fatalf("normalize: %v", err) + } + node.ID = "nod_manual" + node.ResourceID = "res_manual" + node.ExitResourceID = "" + node.ExitReady = true + node.Status = "active" + customer := &Customer{ID: "cus_1", Name: "C", Credentials: &Credentials{ + ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "pw", ShortID: "abcd0001ef01", + }} + d := manualData() + d.Nodes = []*Node{node} + d.Customers = []*Customer{customer} + d.NodeClients = []*NodeClient{{ID: "ncl_1", NodeID: node.ID, CustomerID: "cus_1", Status: "active"}} + + resource, err := resourceForNode(d, node, nil) + if err != nil { + t.Fatalf("resourceForNode: %v", err) + } + outbounds := resource["outbounds"].([]any) + outbound := outbounds[0].(map[string]any) + if outbound["server"] != "203.0.113.9" || outbound["type"] != "shadowsocks" { + t.Fatalf("outbound does not target the manual endpoint: %v", outbound) + } + if outbound["password"] != "FRYXGBkaGxwdHh8gISIjJA==:FRYXGBkaGxwdHh8gISIjJA==" { + t.Fatalf("outbound password wrong: %v", outbound["password"]) + } + rules := resource["routingRules"].([]any) + if rules[0].(map[string]any)["outbound"] != outbound["tag"] { + t.Fatalf("routing rule does not point at the exit outbound") + } + + jobsBefore := len(d.Jobs) + if err := RebuildNode(d, node, true); err != nil { + t.Fatalf("RebuildNode: %v", err) + } + for _, job := range d.Jobs[jobsBefore:] { + if job.Payload != nil && job.Payload.Kind == "node-exit" { + t.Fatalf("manual node must not queue exit jobs") + } + if job.ServerID != "srv_entry" { + t.Fatalf("job queued for a non-entry server: %s", job.ServerID) + } + } + if !node.ExitReady { + t.Fatalf("manual node must stay exit-ready") + } + + // Deploy validation must not look up an exit server for manual nodes + // (regression: this dereferenced a nil exit server and panicked). + d.Servers[0].Status = "online" + if err := ValidateNodeDeploy(d, node); err != nil { + t.Fatalf("ValidateNodeDeploy for a manual node: %v", err) + } +} diff --git a/panel-go/orchestrator.go b/panel-go/orchestrator.go index 134917d..488866d 100644 --- a/panel-go/orchestrator.go +++ b/panel-go/orchestrator.go @@ -59,6 +59,13 @@ func forwardNode(node *Node) bool { return topology == "forward" } +// manualExit reports whether a forward node's exit is an unmanaged landing +// endpoint (round-2 item 8): no exit server, no exit resource, nothing to +// deploy on the far side. +func manualExit(node *Node) bool { + return forwardNode(node) && node.ExitMode == "manual" +} + func tagForNode(node *Node) string { var sb strings.Builder for _, r := range node.ID { @@ -263,7 +270,10 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any return nil, err } var exitServer *Server - if forwardNode(node) { + exitAddress := "" + if manualExit(node) { + exitAddress = node.ExitAddress + } else if forwardNode(node) { exitServer = orchFindServer(d, xbDeref(node.ExitServerID)) if exitServer == nil { return nil, orchNotFound("出口服务器") @@ -295,6 +305,7 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any Server: server, BindAddress: bindAddress, ExitServer: exitServer, + ExitAddress: exitAddress, }) } @@ -356,7 +367,7 @@ func RebuildNode(d *Data, node *Node, includeExit bool) error { } payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node)) QueueJob(d, node.ServerID, node.ID, "apply_resource", payload) - if forwardNode(node) && includeExit { + if forwardNode(node) && includeExit && !manualExit(node) { exitResource, err := resourceForNodeExit(d, node) if err != nil { return err @@ -376,7 +387,7 @@ func DeriveNodeStatus(node *Node) { } return } - if forwardNode(node) && !node.ExitReady { + if forwardNode(node) && !node.ExitReady && !manualExit(node) { if node.Status != "degraded" { node.Status = "deploying" } @@ -631,14 +642,14 @@ func ValidateNodeDeploy(d *Data, node *Node) error { return orchNotFound("服务器") } var exitServer *Server - if !direct { + if !direct && !manualExit(node) { exitServer = orchFindServer(d, xbDeref(node.ExitServerID)) if exitServer == nil { return orchNotFound("出口服务器") } } targets := []*Server{server} - if !direct { + if exitServer != nil { targets = append(targets, exitServer) } for _, target := range targets { @@ -691,7 +702,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error { } } servers := []*Server{server} - if !direct { + if exitServer != nil { servers = []*Server{server, exitServer} } nowMs := time.Now().UnixMilli() @@ -736,7 +747,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error { if NodePortInUse(d, node.ServerID, node.Port, node.ID) { return httpErr(409, "端口 %d 已被该服务器上的其他节点占用", node.Port) } - if !direct && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) { + if !direct && !manualExit(node) && node.ExitPort != nil && NodePortInUse(d, xbDeref(node.ExitServerID), *node.ExitPort, node.ID) { return httpErr(409, "出口端口 %d 已被该服务器上的其他节点占用", *node.ExitPort) } return nil diff --git a/panel-go/protocols.go b/panel-go/protocols.go index 142cc8d..f173daf 100644 --- a/panel-go/protocols.go +++ b/panel-go/protocols.go @@ -499,6 +499,9 @@ type BuildNodeResourceParams struct { Server *Server BindAddress string ExitServer *Server + // ExitAddress, when non-empty, is the manual landing endpoint the + // entry outbound connects to (no managed exit server involved). + ExitAddress string } func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) { @@ -522,9 +525,13 @@ func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) { if !forward { outbound = map[string]any{"type": "direct", "tag": outboundTag} } else { - exitAddr, err := serverAddress(p.ExitServer, networkMode) - if err != nil { - return nil, err + exitAddr := p.ExitAddress + if exitAddr == "" { + var err error + exitAddr, err = serverAddress(p.ExitServer, networkMode) + if err != nil { + return nil, err + } } base := map[string]any{"tag": outboundTag, "server": exitAddr, "server_port": xbDerefInt(node.ExitPort)} creds := node.ExitCredentials diff --git a/panel-go/types.go b/panel-go/types.go index 00c3630..b9a39d0 100644 --- a/panel-go/types.go +++ b/panel-go/types.go @@ -703,13 +703,20 @@ func (m MeteredTraffic) MarshalJSON() ([]byte, error) { } type Node struct { - ID string `json:"id"` - Name string `json:"name"` - ServerID string `json:"serverId"` - ExitServerID *string `json:"exitServerId"` - Protocol string `json:"protocol"` - Topology string `json:"topology"` - ExitProtocol *string `json:"exitProtocol"` + ID string `json:"id"` + Name string `json:"name"` + ServerID string `json:"serverId"` + ExitServerID *string `json:"exitServerId"` + Protocol string `json:"protocol"` + Topology string `json:"topology"` + ExitProtocol *string `json:"exitProtocol"` + // ExitMode selects the forward exit target: "" / "managed" is a panel + // server (ExitServerID); "manual" is an unmanaged landing endpoint + // described by ExitAddress + ExitPort + ExitProtocol + ExitCredentials + // (round-2 item 8). Manual exits have no exit resource and are always + // ExitReady — the panel only builds the entry-side outbound. + ExitMode string `json:"exitMode,omitempty"` + ExitAddress string `json:"exitAddress,omitempty"` NetworkMode string `json:"networkMode"` RealityServerName string `json:"realityServerName"` RealityDestPort int `json:"realityDestPort"` diff --git a/public/app.js b/public/app.js index bc3dd98..99eb2f9 100644 --- a/public/app.js +++ b/public/app.js @@ -906,7 +906,7 @@ function protocolOptions(role, current) { function nodeForm(item = null) { const node = item || { topology: 'forward', protocol: 'vless-reality-vision', exitProtocol: 'shadowsocks-2022-aes128', portMode: 'random', exitPortMode: 'random', networkMode: 'ipv4', realityServerName: 'www.tesla.com', realityDestPort: 443 }; const preset = state.realityPresets.find((entry) => entry.serverName === node.realityServerName) || { id: 'custom' }; - openModal(item ? '编辑节点' : '新建节点', `
+ openModal(item ? '编辑节点' : '新建节点', ` @@ -916,9 +916,15 @@ function nodeForm(item = null) {
出口与转发传输
- +
+ + + + + +
`, true); syncNodeForm(); } @@ -936,6 +942,77 @@ function syncNodeForm() { form.elements[port].disabled = !fixed; form.elements[port].required = fixed; } + // Manual landing endpoint (round-2 item 8): swap the managed exit-server + // picker for address + credential fields; the port is always explicit. + const exitManual = !direct && (form.dataset.exitMode || 'managed') === 'manual'; + $$('[data-managed-only]', form).forEach((element) => element.classList.toggle('hidden', direct || exitManual)); + $$('[data-manual-only]', form).forEach((element) => element.classList.toggle('hidden', !exitManual)); + $$('#exit-mode-seg button', form).forEach((button) => button.classList.toggle('active', button.dataset.mode === (form.dataset.exitMode || 'managed'))); + form.elements.exitServerId.required = !direct && !exitManual; + if (form.elements.exitAddress) form.elements.exitAddress.required = exitManual; + const exitProtocol = form.elements.exitProtocol.value || ''; + const credFor = { vless: exitProtocol === 'vless-tcp', socksuser: exitProtocol === 'socks5-auth', password: exitProtocol === 'socks5-auth' || exitProtocol.startsWith('shadowsocks') }; + $$('[data-exit-cred]', form).forEach((element) => element.classList.toggle('hidden', !exitManual || !credFor[element.dataset.exitCred])); + if (exitManual) { + form.elements.exitPortMode.disabled = true; + form.elements.exitPort.disabled = false; + form.elements.exitPort.required = true; + } +} + +const EXIT_LINK_METHODS = { '2022-blake3-aes-128-gcm': 'shadowsocks-2022-aes128', '2022-blake3-aes-256-gcm': 'shadowsocks-2022-aes256', 'aes-128-gcm': 'shadowsocks-aes128-gcm', 'aes-256-gcm': 'shadowsocks-aes256-gcm' }; +function parseExitLink(text) { + const link = String(text || '').trim(); + if (!link) return null; + try { + if (link.startsWith('vless://')) { + const url = new URL(link); + if (!url.hostname || !url.port || !url.username) return null; + return { protocol: 'vless-tcp', address: url.hostname, port: Number(url.port), clientId: decodeURIComponent(url.username) }; + } + if (link.startsWith('ss://')) { + const rest = link.slice(5).split('#')[0]; + const at = rest.lastIndexOf('@'); + if (at < 0) return null; + let userinfo = rest.slice(0, at); + const hostport = rest.slice(at + 1).split('?')[0].split('/')[0]; + if (!userinfo.includes(':')) { + userinfo = atob(userinfo.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(userinfo.length / 4) * 4, '=')); + } else { + userinfo = decodeURIComponent(userinfo); + } + const sep = userinfo.indexOf(':'); + if (sep < 0) return null; + const protocol = EXIT_LINK_METHODS[userinfo.slice(0, sep)]; + if (!protocol) return null; + const hostMatch = hostport.match(/^\[(.+)\]:(\d+)$/) || hostport.match(/^(.+):(\d+)$/); + if (!hostMatch) return null; + // The password is everything after "method:" — for SS2022 links that + // is the combined serverKey:userKey string, which is exactly what the + // entry outbound sends. + return { protocol, address: hostMatch[1], port: Number(hostMatch[2]), password: userinfo.slice(sep + 1) }; + } + if (link.startsWith('socks5://') || link.startsWith('socks://')) { + const url = new URL(link.replace(/^socks:\/\//, 'socks5://')); + if (!url.hostname || !url.port) return null; + return { protocol: 'socks5-auth', address: url.hostname, port: Number(url.port), socksUser: decodeURIComponent(url.username), password: decodeURIComponent(url.password) }; + } + } catch { return null; } + return null; +} +function applyExitLink(input) { + const form = $('#node-form'); if (!form) return; + const parsed = parseExitLink(input.value); + if (!parsed) { toast('无法识别该链接', true); return; } + form.dataset.exitMode = 'manual'; + form.elements.exitAddress.value = parsed.address; + form.elements.exitPort.value = parsed.port; + form.elements.exitProtocol.value = parsed.protocol; + if (parsed.clientId) form.elements.exitClientId.value = parsed.clientId; + if (parsed.socksUser) form.elements.exitSocksUser.value = parsed.socksUser; + if (parsed.password) form.elements.exitPassword.value = parsed.password; + syncNodeForm(); + toast('已从链接填入落地端点'); } function nodeClientsModal(node) { const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id); @@ -1037,7 +1114,15 @@ 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) }; + 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 }; + if (payload.topology !== 'direct' && payload.exitMode === 'manual') { + const creds = {}; + if (data.get('exitClientId')) creds.clientId = data.get('exitClientId'); + if (data.get('exitPassword')) creds.password = data.get('exitPassword'); + if (data.get('exitSocksUser')) creds.socksUser = data.get('exitSocksUser'); + if (Object.keys(creds).length) payload.exitCredentials = creds; + 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'); } else if (form.id === 'account-form') { @@ -1150,6 +1235,10 @@ document.addEventListener('click', async (event) => { api(`/api/customers/${itemId}/credentials`).then((data) => { customerFormState.credentials = data; renderCredentialsPane(result.customer); }).catch(() => {}); } else if (modalOpen()) subscriptionModal(result.customer); } + else if (action === 'exit-mode') { + const form = $('#node-form'); + if (form) { form.dataset.exitMode = button.dataset.mode; syncNodeForm(); } + } else if (action === 'regen-credentials') { const customerId = $('#customer-form').dataset.customerId; if (!await confirmModal('重新生成该客户的凭据?全部节点同时更换,现有连接将立即失效,需要重新导入订阅。', '重新生成')) return; @@ -1248,6 +1337,7 @@ document.addEventListener('change', async (event) => { } else if (target.matches('[data-check-all]')) { $$('[data-row-check]').forEach((box) => { box.checked = target.checked; }); } else if (target.matches('[data-node-sync]')) syncNodeForm(); + else if (target.matches('[data-exit-link]')) applyExitLink(target); else if (target.matches('[data-reality-preset]')) { const option = target.selectedOptions[0]; const form = target.form; if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; } diff --git a/public/styles.css b/public/styles.css index 817a94c..49f1304 100644 --- a/public/styles.css +++ b/public/styles.css @@ -322,6 +322,9 @@ label>input:focus,label>select:focus,label>textarea:focus{border-color:var(--pri .mono{font-family:var(--mono);font-size:12.5px;word-break:break-all} /* 页签 */ +.seg{display:inline-flex;border:1px solid var(--border);border-radius:8px;overflow:hidden} +.seg button{padding:6px 16px;background:var(--card);border:none;color:var(--text-2);cursor:pointer;font:inherit;font-size:13px} +.seg button.active{background:var(--primary);color:#fff} .tabs{display:flex;gap:2px;border-bottom:1px solid var(--border-soft);margin-bottom:14px} .tabs button{border:none;background:transparent;padding:7px 14px;font-size:13.5px;color:var(--text-2);cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px} .tabs button.active{color:var(--primary-text);border-bottom-color:var(--primary);font-weight:500}