Files
NexusGate/panel-go/manualexit_test.go
aaaamoooo 0037116edc 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
2026-10-21 11:18:00 +08:00

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