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