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:
+483
-6
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user