feat: manual landing endpoints for forward nodes (item 8)

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
This commit is contained in:
aaaamoooo
2026-10-20 21:36:20 +00:00
parent ad18e16ac6
commit 18048f309e
9 changed files with 911 additions and 31 deletions
+483 -6
View File
@@ -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-<nod>#3": {
"privateKey": "<rpriv>#1",
"publicKey": "<rpub>#2",
"updatedAt": "<ts>"
},
"reality-<nod>#2": {
"privateKey": "<rpriv>#2",
"publicKey": "<rpub>#1",
"updatedAt": "<ts>"
},
"reality-<nod>#3": {
"privateKey": "<rpriv>#1",
"publicKey": "<rpub>#2",
"updatedAt": "<ts>"
}
},
"nodeClients": [
@@ -3608,6 +3608,483 @@
},
"status": 401
}
},
"manualCreate": {
"body": {
"node": {
"artifacts": {},
"createdAt": "<ts>",
"entryReady": false,
"error": null,
"exitAddress": "203.0.113.9",
"exitMode": "manual",
"exitPort": "<port>#6",
"exitPortMode": "fixed",
"exitProtocol": "shadowsocks-2022-aes128",
"exitReady": true,
"exitResourceId": "",
"exitServerId": null,
"id": "<nod>#4",
"lastUsageAt": null,
"meteredTraffic": null,
"name": "N4-Manual",
"networkMode": "ipv4",
"pendingDelete": false,
"port": "<port>#7",
"portMode": "fixed",
"protocol": "vless-reality-vision",
"reality": {
"destPort": 443,
"keyId": "reality-<nod>#4",
"serverName": "www.amd.com"
},
"realityDestPort": 443,
"realityServerName": "www.amd.com",
"resourceId": "<res>#7",
"retiredUsageEpochs": [],
"serverId": "<srv>#1",
"sortIndex": 3,
"status": "draft",
"topology": "forward",
"updatedAt": "<ts>",
"usageCounters": null,
"wsPath": "<secret>#13"
}
},
"status": 201
},
"manualAssign": {
"body": {
"nodeClient": {
"createdAt": "<ts>",
"customerId": "<cus>#3",
"id": "<ncl>#5",
"nodeId": "<nod>#4",
"status": "active",
"updatedAt": "<ts>"
}
},
"status": 200
},
"manualDeploy": {
"body": {
"ok": true
},
"status": 202
},
"manualJobs": [
{
"complete": {
"job": {
"action": "apply_resource",
"attempts": 1,
"completedAt": "<ts>",
"createdAt": "<ts>",
"error": null,
"id": "<job>#7",
"leaseUntil": null,
"nodeId": "<nod>#4",
"payload": {
"keyMaterial": {
"reality-<nod>#4": {
"privateKey": "<rpriv>#3",
"publicKey": "<rpub>#3"
}
},
"kind": "node",
"resource": {
"engine": "sing-box",
"id": "<res>#7",
"inbounds": [
{
"listen": "<hostip>",
"listen_port": "<port>#7",
"tag": "ng-<nod>#4-in",
"tls": {
"enabled": true,
"reality": {
"enabled": true,
"handshake": {
"server": "www.amd.com",
"server_port": "<port>#5"
},
"private_key": "${REALITY_PRIVATE:reality-<nod>#4}",
"short_id": [
"<secret>#8"
]
},
"server_name": "www.amd.com"
},
"type": "vless",
"users": [
{
"flow": "xtls-rprx-vision",
"name": "ng:<cus>#3",
"uuid": "<uuid>#3"
}
]
}
],
"meta": {
"kind": "node",
"metricsTag": "ng-<nod>#4-in",
"nodeId": "<nod>#4",
"protocol": "vless-reality-vision"
},
"outbounds": [
{
"method": "2022-blake3-aes-128-gcm",
"password": "<secret>#14",
"server": "203.0.113.9",
"server_port": "<port>#6",
"tag": "ng-<nod>#4-exit",
"type": "shadowsocks"
}
],
"routingRules": [
{
"action": "route",
"inbound": [
"ng-<nod>#4-in"
],
"outbound": "ng-<nod>#4-exit"
}
]
}
},
"serverId": "<srv>#1",
"startedAt": "<ts>",
"status": "completed",
"updatedAt": "<ts>"
}
},
"job": {
"action": "apply_resource",
"attempts": 1,
"createdAt": "<ts>",
"error": null,
"id": "<job>#7",
"leaseUntil": "<ts>",
"nodeId": "<nod>#4",
"payload": {
"keyMaterial": {
"reality-<nod>#4": {
"privateKey": "<rpriv>#3",
"publicKey": "<rpub>#3"
}
},
"kind": "node",
"resource": {
"engine": "sing-box",
"id": "<res>#7",
"inbounds": [
{
"listen": "<hostip>",
"listen_port": "<port>#7",
"tag": "ng-<nod>#4-in",
"tls": {
"enabled": true,
"reality": {
"enabled": true,
"handshake": {
"server": "www.amd.com",
"server_port": "<port>#5"
},
"private_key": "${REALITY_PRIVATE:reality-<nod>#4}",
"short_id": [
"<secret>#8"
]
},
"server_name": "www.amd.com"
},
"type": "vless",
"users": [
{
"flow": "xtls-rprx-vision",
"name": "ng:<cus>#3",
"uuid": "<uuid>#3"
}
]
}
],
"meta": {
"kind": "node",
"metricsTag": "ng-<nod>#4-in",
"nodeId": "<nod>#4",
"protocol": "vless-reality-vision"
},
"outbounds": [
{
"method": "2022-blake3-aes-128-gcm",
"password": "<secret>#14",
"server": "203.0.113.9",
"server_port": "<port>#6",
"tag": "ng-<nod>#4-exit",
"type": "shadowsocks"
}
],
"routingRules": [
{
"action": "route",
"inbound": [
"ng-<nod>#4-in"
],
"outbound": "ng-<nod>#4-exit"
}
]
}
},
"serverId": "<srv>#1",
"startedAt": "<ts>",
"status": "running",
"updatedAt": "<ts>"
}
}
],
"manualNodes": {
"body": {
"nodeClients": [
{
"clientUri": "vless://<uuid>#1@entry.example.com:<port>#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#1&sid=<secret>#2&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed",
"createdAt": "<ts>",
"customerId": "<cus>#1",
"id": "<ncl>#1",
"nodeId": "<nod>#2",
"status": "active",
"updatedAt": "<ts>"
},
{
"clientUri": "vless://<uuid>#2@entry.example.com:<port>#2?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#1&sid=<secret>#4&type=tcp&headerType=none&flow=xtls-rprx-vision#N1-renamed",
"createdAt": "<ts>",
"customerId": "<cus>#2",
"id": "<ncl>#2",
"nodeId": "<nod>#2",
"status": "active",
"updatedAt": "<ts>"
},
{
"clientUri": "ss://<ssblob>#1@entry.example.com:<port>#3#N2",
"createdAt": "<ts>",
"customerId": "<cus>#1",
"id": "<ncl>#3",
"nodeId": "<nod>#1",
"status": "active",
"updatedAt": "<ts>"
},
{
"clientUri": "vless://<uuid>#3@s3.example.com:<port>#4?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#2&sid=<secret>#8&type=tcp&headerType=none#N3",
"createdAt": "<ts>",
"customerId": "<cus>#3",
"id": "<ncl>#4",
"nodeId": "<nod>#3",
"status": "active",
"updatedAt": "<ts>"
},
{
"clientUri": "vless://<uuid>#3@entry.example.com:<port>#7?encryption=none&security=reality&sni=www.amd.com&fp=chrome&pbk=<rpub>#3&sid=<secret>#8&type=tcp&headerType=none&flow=xtls-rprx-vision#N4-Manual",
"createdAt": "<ts>",
"customerId": "<cus>#3",
"id": "<ncl>#5",
"nodeId": "<nod>#4",
"status": "active",
"updatedAt": "<ts>"
}
],
"nodes": [
{
"artifacts": {
"realityPublicKey": "<rpub>#1"
},
"autoRemoving": false,
"clientCount": 2,
"createdAt": "<ts>",
"entryReady": true,
"error": null,
"exitPort": "<port>#1",
"exitPortMode": "random",
"exitProtocol": "shadowsocks-2022-aes256",
"exitReady": true,
"exitResourceId": "<res>#1",
"exitServerId": "<srv>#2",
"id": "<nod>#2",
"lastActiveAt": "<ts>",
"lastSampleAt": "<ts>",
"lastUsageAt": "<ts>",
"meteredTraffic": {
"downlink": 14000,
"uplink": 12000
},
"name": "N1-renamed",
"networkMode": "ipv4",
"onlineCount": 1,
"onlineCustomerIds": [
"<cus>#1"
],
"pendingDelete": false,
"port": "<port>#2",
"portMode": "random",
"protocol": "vless-reality-vision",
"reality": {
"destPort": 443,
"keyId": "reality-<nod>#2",
"serverName": "www.amd.com"
},
"realityDestPort": 443,
"realityServerName": "www.amd.com",
"resourceId": "<res>#2",
"retiredUsageEpochs": [
"diff-epoch-1"
],
"serverId": "<srv>#1",
"sortIndex": 0,
"speedAt": "<ts>",
"speedDownBps": "<speed+>",
"speedUpBps": "<speed+>",
"status": "active",
"topology": "forward",
"updatedAt": "<ts>",
"usageCounters": {
"downlink": 9000,
"epoch": "golden-epoch-1",
"uplink": 8000
},
"wsPath": "<secret>#5"
},
{
"artifacts": {},
"autoRemoving": false,
"clientCount": 1,
"createdAt": "<ts>",
"entryReady": true,
"error": null,
"exitPort": null,
"exitPortMode": null,
"exitProtocol": null,
"exitReady": false,
"exitResourceId": "<res>#3",
"exitServerId": null,
"id": "<nod>#1",
"lastUsageAt": null,
"meteredTraffic": null,
"name": "N2",
"networkMode": "ipv4",
"onlineCount": 1,
"onlineCustomerIds": [
"<cus>#1"
],
"pendingDelete": false,
"port": "<port>#3",
"portMode": "random",
"protocol": "shadowsocks-2022-aes128",
"realityDestPort": 443,
"realityServerName": "www.tesla.com",
"resourceId": "<res>#4",
"retiredUsageEpochs": [],
"serverId": "<srv>#1",
"sortIndex": 1,
"speedDownBps": 0,
"speedUpBps": 0,
"status": "active",
"topology": "direct",
"updatedAt": "<ts>",
"usageCounters": null,
"wsPath": "<secret>#6"
},
{
"artifacts": {
"realityPublicKey": "<rpub>#2"
},
"autoRemoving": false,
"clientCount": 1,
"createdAt": "<ts>",
"entryReady": true,
"error": null,
"exitPort": null,
"exitPortMode": null,
"exitProtocol": null,
"exitReady": false,
"exitResourceId": "<res>#5",
"exitServerId": null,
"id": "<nod>#3",
"lastUsageAt": null,
"meteredTraffic": null,
"name": "N3",
"networkMode": "ipv4",
"onlineCount": 0,
"onlineCustomerIds": [],
"pendingDelete": false,
"port": "<port>#4",
"portMode": "random",
"protocol": "vless-reality",
"reality": {
"destPort": 443,
"keyId": "reality-<nod>#3",
"serverName": "www.amd.com"
},
"realityDestPort": 443,
"realityServerName": "www.amd.com",
"resourceId": "<res>#6",
"retiredUsageEpochs": [],
"serverId": "<srv>#3",
"sortIndex": 2,
"speedDownBps": 0,
"speedUpBps": 0,
"status": "active",
"topology": "direct",
"updatedAt": "<ts>",
"usageCounters": null,
"wsPath": "<secret>#9"
},
{
"artifacts": {
"realityPublicKey": "<rpub>#3"
},
"autoRemoving": false,
"clientCount": 1,
"createdAt": "<ts>",
"entryReady": true,
"error": null,
"exitAddress": "203.0.113.9",
"exitMode": "manual",
"exitPort": "<port>#6",
"exitPortMode": "fixed",
"exitProtocol": "shadowsocks-2022-aes128",
"exitReady": true,
"exitResourceId": "",
"exitServerId": null,
"id": "<nod>#4",
"lastUsageAt": null,
"meteredTraffic": null,
"name": "N4-Manual",
"networkMode": "ipv4",
"onlineCount": 0,
"onlineCustomerIds": [],
"pendingDelete": false,
"port": "<port>#7",
"portMode": "fixed",
"protocol": "vless-reality-vision",
"reality": {
"destPort": 443,
"keyId": "reality-<nod>#4",
"serverName": "www.amd.com"
},
"realityDestPort": 443,
"realityServerName": "www.amd.com",
"resourceId": "<res>#7",
"retiredUsageEpochs": [],
"serverId": "<srv>#1",
"sortIndex": 3,
"speedDownBps": 0,
"speedUpBps": 0,
"status": "active",
"topology": "forward",
"updatedAt": "<ts>",
"usageCounters": null,
"wsPath": "<secret>#13"
}
]
},
"status": 200
}
}
}
+17
View File
@@ -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);
+132 -5
View File
@@ -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
+141
View File
@@ -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)
}
}
+18 -7
View File
@@ -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
+10 -3
View File
@@ -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
+14 -7
View File
@@ -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"`
+93 -3
View File
@@ -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 ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
openModal(item ? '编辑节点' : '新建节点', `<form id="node-form" class="form-grid" data-exit-mode="${node.exitMode === 'manual' ? 'manual' : 'managed'}"><input type="hidden" name="resourceId" value="${esc(item ? item.id : '')}">
<label class="wide">节点名称<input name="name" value="${esc(node.name || '')}" placeholder="新加坡入口 01" required></label>
<label>服务器<select name="serverId" required>${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.serverId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<label>网络栈<select name="networkMode"><option value="ipv4"${selected('ipv4', node.networkMode || 'ipv4')}>IPv4</option><option value="ipv6"${selected('ipv6', node.networkMode)}>IPv6</option><option value="dual"${selected('dual', node.networkMode)}>双栈监听</option></select></label>
@@ -916,9 +916,15 @@ function nodeForm(item = null) {
<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>拓扑<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>
<label data-forward-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<div class="wide" data-forward-only><div class="seg" 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>
<label class="wide" data-forward-only data-manual-only>从链接导入<input data-exit-link placeholder="粘贴 vless:// / ss:// / socks5:// 链接,自动填入下方字段"></label>
<label data-forward-only data-managed-only>出口 / 落地设备<select name="exitServerId">${state.servers.map((server) => `<option value="${esc(server.id)}"${selected(server.id, node.exitServerId)}>${esc(server.name)} · ${esc(roleText[server.role])}</option>`).join('')}</select></label>
<label data-forward-only data-manual-only>落地地址<input name="exitAddress" value="${esc(node.exitAddress || '')}" placeholder="IP 或域名"></label>
<label data-forward-only>入口到出口协议<select name="exitProtocol" data-node-sync>${protocolOptions('exit-transport', node.exitProtocol)}</select></label>
<label data-forward-only>出口端口<div class="inline-fields"><select name="exitPortMode" data-node-sync><option value="random"${selected('random', node.exitPortMode)}>范围内随机</option><option value="fixed"${selected('fixed', node.exitPortMode)}>固定端口</option></select><input name="exitPort" type="number" value="${esc(node.exitPort || '')}" placeholder="固定时填写" min="1024" max="65535"></div></label>
<label data-forward-only data-manual-only data-exit-cred="vless">VLESS 用户 ID<input name="exitClientId" placeholder="${item ? '留空保持不变' : '链接中的 UUID'}"></label>
<label data-forward-only data-manual-only data-exit-cred="socksuser">SOCKS5 用户名<input name="exitSocksUser" placeholder="${item ? '留空保持不变' : ''}"></label>
<label data-forward-only data-manual-only data-exit-cred="password">落地密码<input name="exitPassword" placeholder="${item ? '留空保持不变' : ''}"></label>
<div class="form-actions wide"><button type="button" class="btn" data-close>取消</button><button class="btn primary" type="submit">${item ? '保存修改' : '创建节点'}</button></div></form>`, 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; }
+3
View File
@@ -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}