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