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
}
}
}