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:
+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
|
||||
|
||||
Reference in New Issue
Block a user