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"`