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": {
|
"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."
|
"note": "Go panel golden baseline; see README.md in this directory."
|
||||||
},
|
},
|
||||||
"sections": {
|
"sections": {
|
||||||
@@ -2835,15 +2835,15 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"keyStore": {
|
"keyStore": {
|
||||||
"reality-<nod>#3": {
|
|
||||||
"privateKey": "<rpriv>#1",
|
|
||||||
"publicKey": "<rpub>#2",
|
|
||||||
"updatedAt": "<ts>"
|
|
||||||
},
|
|
||||||
"reality-<nod>#2": {
|
"reality-<nod>#2": {
|
||||||
"privateKey": "<rpriv>#2",
|
"privateKey": "<rpriv>#2",
|
||||||
"publicKey": "<rpub>#1",
|
"publicKey": "<rpub>#1",
|
||||||
"updatedAt": "<ts>"
|
"updatedAt": "<ts>"
|
||||||
|
},
|
||||||
|
"reality-<nod>#3": {
|
||||||
|
"privateKey": "<rpriv>#1",
|
||||||
|
"publicKey": "<rpub>#2",
|
||||||
|
"updatedAt": "<ts>"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"nodeClients": [
|
"nodeClients": [
|
||||||
@@ -3608,6 +3608,483 @@
|
|||||||
},
|
},
|
||||||
"status": 401
|
"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;
|
return sections;
|
||||||
} finally {
|
} finally {
|
||||||
await stopPanel(proc);
|
await stopPanel(proc);
|
||||||
|
|||||||
+132
-5
@@ -7,6 +7,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -208,6 +209,17 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
|||||||
topology = "forward"
|
topology = "forward"
|
||||||
}
|
}
|
||||||
direct := topology == "direct"
|
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
|
protocol := next.Protocol
|
||||||
if _, ok := body["protocol"]; ok {
|
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, "服务器不存在")
|
return nil, httpErr(404, "服务器不存在")
|
||||||
}
|
}
|
||||||
var exitServerID *string
|
var exitServerID *string
|
||||||
if direct {
|
if direct || manual {
|
||||||
exitServerID = nil
|
exitServerID = nil
|
||||||
} else if _, ok := body["exitServerId"]; ok {
|
} else if _, ok := body["exitServerId"]; ok {
|
||||||
id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120)
|
id, err := RequiredText(body["exitServerId"], "出口服务器 ID", 120)
|
||||||
@@ -243,7 +255,7 @@ func NormalizeNode(body map[string]any, current *Node, data *Data) (*Node, error
|
|||||||
} else {
|
} else {
|
||||||
exitServerID = next.ExitServerID
|
exitServerID = next.ExitServerID
|
||||||
}
|
}
|
||||||
if !direct {
|
if !direct && !manual {
|
||||||
found := false
|
found := false
|
||||||
for _, item := range data.Servers {
|
for _, item := range data.Servers {
|
||||||
if item.ID == strVal(exitServerID) {
|
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.Protocol = protocol
|
||||||
next.Topology = topology
|
next.Topology = topology
|
||||||
next.ExitServerID = exitServerID
|
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 {
|
if direct {
|
||||||
next.ExitProtocol = nil
|
next.ExitProtocol = nil
|
||||||
} else if _, ok := body["exitProtocol"]; ok {
|
} 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
|
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)
|
exitPortMode := strVal(next.ExitPortMode)
|
||||||
if _, ok := body["exitPortMode"]; ok {
|
if _, ok := body["exitPortMode"]; ok {
|
||||||
if s, _ := body["exitPortMode"].(string); s == "fixed" {
|
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
|
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 == "" {
|
if IsRealityProtocol(protocol) && next.RealityServerName == "" {
|
||||||
return nil, httpErr(400, "Reality SNI 不能为空")
|
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.ExitReady = false
|
||||||
next.ResourceID = NewID("res")
|
next.ResourceID = NewID("res")
|
||||||
next.ExitResourceID = 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.Artifacts = map[string]json.RawMessage{}
|
||||||
next.Error = nil
|
next.Error = nil
|
||||||
next.PendingDelete = false
|
next.PendingDelete = false
|
||||||
@@ -2454,6 +2568,7 @@ func (a *App) handleAdminApi(w http.ResponseWriter, r *http.Request, pathname st
|
|||||||
}
|
}
|
||||||
oldServerID := item.ServerID
|
oldServerID := item.ServerID
|
||||||
oldExitServerID := strVal(item.ExitServerID)
|
oldExitServerID := strVal(item.ExitServerID)
|
||||||
|
oldExitResourceID := item.ExitResourceID
|
||||||
oldForward := forwardNode(item)
|
oldForward := forwardNode(item)
|
||||||
next, err := NormalizeNode(body, item, d)
|
next, err := NormalizeNode(body, item, d)
|
||||||
if err != nil {
|
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"})
|
QueueJob(d, oldServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ResourceID, Kind: "node"})
|
||||||
item.EntryReady = false
|
item.EntryReady = false
|
||||||
}
|
}
|
||||||
if oldForward && (!forwardNode(item) || strVal(item.ExitServerID) != oldExitServerID) {
|
if oldForward && oldExitResourceID != "" && (!forwardNode(item) || manualExit(item) || strVal(item.ExitServerID) != oldExitServerID) {
|
||||||
QueueJob(d, oldExitServerID, item.ID, "delete_resource", &JobPayload{ResourceID: item.ExitResourceID, Kind: "node-exit"})
|
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
|
item.ExitReady = false
|
||||||
}
|
}
|
||||||
if item.Status == "active" || item.Status == "deploying" || item.Status == "failed" || item.Status == "degraded" {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if manualExit(item) {
|
||||||
|
item.ExitReady = true
|
||||||
|
}
|
||||||
item.UpdatedAt = NowISO()
|
item.UpdatedAt = NowISO()
|
||||||
Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name})
|
Audit(d, actor, "update_node", item.ID, map[string]any{"name": item.Name})
|
||||||
return cloneJSONValue(item), nil
|
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"
|
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 {
|
func tagForNode(node *Node) string {
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
for _, r := range node.ID {
|
for _, r := range node.ID {
|
||||||
@@ -263,7 +270,10 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var exitServer *Server
|
var exitServer *Server
|
||||||
if forwardNode(node) {
|
exitAddress := ""
|
||||||
|
if manualExit(node) {
|
||||||
|
exitAddress = node.ExitAddress
|
||||||
|
} else if forwardNode(node) {
|
||||||
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
|
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
|
||||||
if exitServer == nil {
|
if exitServer == nil {
|
||||||
return nil, orchNotFound("出口服务器")
|
return nil, orchNotFound("出口服务器")
|
||||||
@@ -295,6 +305,7 @@ func resourceForNode(d *Data, node *Node, clients []*NodeClient) (map[string]any
|
|||||||
Server: server,
|
Server: server,
|
||||||
BindAddress: bindAddress,
|
BindAddress: bindAddress,
|
||||||
ExitServer: exitServer,
|
ExitServer: exitServer,
|
||||||
|
ExitAddress: exitAddress,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,7 +367,7 @@ func RebuildNode(d *Data, node *Node, includeExit bool) error {
|
|||||||
}
|
}
|
||||||
payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node))
|
payload := payloadWithResource(resource, "node", KeyMaterialForNode(d, node))
|
||||||
QueueJob(d, node.ServerID, node.ID, "apply_resource", payload)
|
QueueJob(d, node.ServerID, node.ID, "apply_resource", payload)
|
||||||
if forwardNode(node) && includeExit {
|
if forwardNode(node) && includeExit && !manualExit(node) {
|
||||||
exitResource, err := resourceForNodeExit(d, node)
|
exitResource, err := resourceForNodeExit(d, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -376,7 +387,7 @@ func DeriveNodeStatus(node *Node) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if forwardNode(node) && !node.ExitReady {
|
if forwardNode(node) && !node.ExitReady && !manualExit(node) {
|
||||||
if node.Status != "degraded" {
|
if node.Status != "degraded" {
|
||||||
node.Status = "deploying"
|
node.Status = "deploying"
|
||||||
}
|
}
|
||||||
@@ -631,14 +642,14 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
|||||||
return orchNotFound("服务器")
|
return orchNotFound("服务器")
|
||||||
}
|
}
|
||||||
var exitServer *Server
|
var exitServer *Server
|
||||||
if !direct {
|
if !direct && !manualExit(node) {
|
||||||
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
|
exitServer = orchFindServer(d, xbDeref(node.ExitServerID))
|
||||||
if exitServer == nil {
|
if exitServer == nil {
|
||||||
return orchNotFound("出口服务器")
|
return orchNotFound("出口服务器")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
targets := []*Server{server}
|
targets := []*Server{server}
|
||||||
if !direct {
|
if exitServer != nil {
|
||||||
targets = append(targets, exitServer)
|
targets = append(targets, exitServer)
|
||||||
}
|
}
|
||||||
for _, target := range targets {
|
for _, target := range targets {
|
||||||
@@ -691,7 +702,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
servers := []*Server{server}
|
servers := []*Server{server}
|
||||||
if !direct {
|
if exitServer != nil {
|
||||||
servers = []*Server{server, exitServer}
|
servers = []*Server{server, exitServer}
|
||||||
}
|
}
|
||||||
nowMs := time.Now().UnixMilli()
|
nowMs := time.Now().UnixMilli()
|
||||||
@@ -736,7 +747,7 @@ func ValidateNodeDeploy(d *Data, node *Node) error {
|
|||||||
if NodePortInUse(d, node.ServerID, node.Port, node.ID) {
|
if NodePortInUse(d, node.ServerID, node.Port, node.ID) {
|
||||||
return httpErr(409, "端口 %d 已被该服务器上的其他节点占用", node.Port)
|
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 httpErr(409, "出口端口 %d 已被该服务器上的其他节点占用", *node.ExitPort)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+10
-3
@@ -499,6 +499,9 @@ type BuildNodeResourceParams struct {
|
|||||||
Server *Server
|
Server *Server
|
||||||
BindAddress string
|
BindAddress string
|
||||||
ExitServer *Server
|
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) {
|
func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
|
||||||
@@ -522,9 +525,13 @@ func BuildNodeResource(p BuildNodeResourceParams) (map[string]any, error) {
|
|||||||
if !forward {
|
if !forward {
|
||||||
outbound = map[string]any{"type": "direct", "tag": outboundTag}
|
outbound = map[string]any{"type": "direct", "tag": outboundTag}
|
||||||
} else {
|
} else {
|
||||||
exitAddr, err := serverAddress(p.ExitServer, networkMode)
|
exitAddr := p.ExitAddress
|
||||||
if err != nil {
|
if exitAddr == "" {
|
||||||
return nil, err
|
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)}
|
base := map[string]any{"tag": outboundTag, "server": exitAddr, "server_port": xbDerefInt(node.ExitPort)}
|
||||||
creds := node.ExitCredentials
|
creds := node.ExitCredentials
|
||||||
|
|||||||
+14
-7
@@ -703,13 +703,20 @@ func (m MeteredTraffic) MarshalJSON() ([]byte, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Node struct {
|
type Node struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ServerID string `json:"serverId"`
|
ServerID string `json:"serverId"`
|
||||||
ExitServerID *string `json:"exitServerId"`
|
ExitServerID *string `json:"exitServerId"`
|
||||||
Protocol string `json:"protocol"`
|
Protocol string `json:"protocol"`
|
||||||
Topology string `json:"topology"`
|
Topology string `json:"topology"`
|
||||||
ExitProtocol *string `json:"exitProtocol"`
|
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"`
|
NetworkMode string `json:"networkMode"`
|
||||||
RealityServerName string `json:"realityServerName"`
|
RealityServerName string `json:"realityServerName"`
|
||||||
RealityDestPort int `json:"realityDestPort"`
|
RealityDestPort int `json:"realityDestPort"`
|
||||||
|
|||||||
+93
-3
@@ -906,7 +906,7 @@ function protocolOptions(role, current) {
|
|||||||
function nodeForm(item = null) {
|
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 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' };
|
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 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="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>
|
<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 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>
|
<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>
|
<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>入口到出口协议<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>出口端口<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);
|
<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();
|
syncNodeForm();
|
||||||
}
|
}
|
||||||
@@ -936,6 +942,77 @@ function syncNodeForm() {
|
|||||||
form.elements[port].disabled = !fixed;
|
form.elements[port].disabled = !fixed;
|
||||||
form.elements[port].required = 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) {
|
function nodeClientsModal(node) {
|
||||||
const assigned = state.nodeClients.filter((nc) => nc.nodeId === node.id);
|
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');
|
closeModal(); toast(resourceId ? '客户信息已更新' : '客户已创建'); await load('customers');
|
||||||
} else if (form.id === 'node-form') {
|
} else if (form.id === 'node-form') {
|
||||||
const resourceId = data.get('resourceId');
|
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) });
|
await api(resourceId ? `/api/nodes/${resourceId}` : '/api/nodes', { method: resourceId ? 'PATCH' : 'POST', body: JSON.stringify(payload) });
|
||||||
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains');
|
closeModal(); toast(resourceId ? '节点已更新,运行中节点已重建下发' : '节点已创建,请分配客户后部署'); await load('chains');
|
||||||
} else if (form.id === 'account-form') {
|
} 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(() => {});
|
api(`/api/customers/${itemId}/credentials`).then((data) => { customerFormState.credentials = data; renderCredentialsPane(result.customer); }).catch(() => {});
|
||||||
} else if (modalOpen()) subscriptionModal(result.customer);
|
} 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') {
|
else if (action === 'regen-credentials') {
|
||||||
const customerId = $('#customer-form').dataset.customerId;
|
const customerId = $('#customer-form').dataset.customerId;
|
||||||
if (!await confirmModal('重新生成该客户的凭据?全部节点同时更换,现有连接将立即失效,需要重新导入订阅。', '重新生成')) return;
|
if (!await confirmModal('重新生成该客户的凭据?全部节点同时更换,现有连接将立即失效,需要重新导入订阅。', '重新生成')) return;
|
||||||
@@ -1248,6 +1337,7 @@ document.addEventListener('change', async (event) => {
|
|||||||
} else if (target.matches('[data-check-all]')) {
|
} else if (target.matches('[data-check-all]')) {
|
||||||
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
|
$$('[data-row-check]').forEach((box) => { box.checked = target.checked; });
|
||||||
} else if (target.matches('[data-node-sync]')) syncNodeForm();
|
} else if (target.matches('[data-node-sync]')) syncNodeForm();
|
||||||
|
else if (target.matches('[data-exit-link]')) applyExitLink(target);
|
||||||
else if (target.matches('[data-reality-preset]')) {
|
else if (target.matches('[data-reality-preset]')) {
|
||||||
const option = target.selectedOptions[0]; const form = target.form;
|
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; }
|
if (option && option.value !== 'custom') { form.elements.realityServerName.value = option.dataset.sni; form.elements.realityDestPort.value = option.dataset.port; }
|
||||||
|
|||||||
@@ -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}
|
.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{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{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}
|
.tabs button.active{color:var(--primary-text);border-bottom-color:var(--primary);font-weight:500}
|
||||||
|
|||||||
Reference in New Issue
Block a user