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
142 lines
4.9 KiB
Go
142 lines
4.9 KiB
Go
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)
|
|
}
|
|
}
|