Item 20: drop the zero-client deploy gate in ValidateNodeDeploy for all protocols, stop RebuildNode from auto-removing a node whose bindings reach zero, and let server/heartbeat reconciliation rebuild zero-client nodes like any other. Nodes are built and deployed first; bindings can be added and removed freely. Item 21: socks5-auth nodes carry one node-level fixed account (node.socksUser/socksPassword, minted at creation or by the store-load migration MigrateSocksNodeCredentials, idempotent). The sing-box inbound's only user is that pair regardless of bindings; subscription links for every bound customer carry the same pair. The generated username avoids the ng: prefix so the agent never attributes the account's traffic to a customer — it stays on the node's inbound counters. Edit form shows both fields (copyable) for socks nodes; NormalizeNode validates operator-supplied usernames.
175 lines
6.5 KiB
Go
175 lines
6.5 KiB
Go
package main
|
|
|
|
// Tests for round-3 items 20+21: build-before-bind (no client-count
|
|
// deploy gate, no auto-removal at zero bindings) and the socks5-auth
|
|
// node-level fixed account.
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func socksNodeData() *Data {
|
|
d := EmptyData()
|
|
d.Servers = []*Server{{ID: "srv_1", Name: "S1", PublicAddress: "entry.example.com", PortRangeStart: 20000, PortRangeEnd: 50000,
|
|
Status: "online", LastSeenAt: StrPtr(NowISO())}}
|
|
d.Customers = []*Customer{
|
|
{ID: "cus_1", Name: "A", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000001", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0001"}},
|
|
{ID: "cus_2", Name: "B", Status: "active", Credentials: &Credentials{ClientID: "11111111-2222-3333-4444-000000000002", RelayPassword: "FRYXGBkaGxwdHh8gISIjJA==", ShortID: "abcd0002"}},
|
|
}
|
|
return d
|
|
}
|
|
|
|
func TestSocksInboundAlwaysFixedAccount(t *testing.T) {
|
|
node := &Node{ID: "nod_sk", Protocol: "socks5-auth", Port: 23462, NetworkMode: "ipv4",
|
|
SocksUser: "fixeduser", SocksPassword: "fixedpass"}
|
|
server := &Server{ID: "srv_1", PublicAddress: "entry.example.com"}
|
|
for _, nClients := range []int{0, 1, 2} {
|
|
clients := []*NodeClient{}
|
|
for i := 0; i < nClients; i++ {
|
|
clients = append(clients, &NodeClient{NodeID: node.ID, CustomerID: "cus_1", Status: "active",
|
|
Credentials: &Credentials{ClientID: "x", RelayPassword: "y", ShortID: "z"}})
|
|
}
|
|
inbound, err := buildSingBoxInbound("t-in", node, clients, server, "")
|
|
if err != nil {
|
|
t.Fatalf("clients=%d: %v", nClients, err)
|
|
}
|
|
users, _ := inbound["users"].([]any)
|
|
if len(users) != 1 {
|
|
t.Fatalf("clients=%d: users = %d, want exactly the fixed account", nClients, len(users))
|
|
}
|
|
u, _ := users[0].(map[string]any)
|
|
if u["username"] != "fixeduser" || u["password"] != "fixedpass" {
|
|
t.Fatalf("clients=%d: user = %v, want the fixed pair", nClients, u)
|
|
}
|
|
}
|
|
// A node without a stored pair gets one minted, never an ng: identity.
|
|
bare := &Node{ID: "nod_bare", Protocol: "socks5-auth", Port: 23463, NetworkMode: "ipv4"}
|
|
if _, err := buildSingBoxInbound("t-in", bare, nil, server, ""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if bare.SocksUser == "" || strings.HasPrefix(bare.SocksUser, "ng:") {
|
|
t.Fatalf("minted user %q must exist and avoid the ng: namespace", bare.SocksUser)
|
|
}
|
|
if raw, err := base64.StdEncoding.DecodeString(bare.SocksPassword); err != nil || len(raw) != 32 {
|
|
t.Fatalf("minted password must be a base64 32-byte key, got %q", bare.SocksPassword)
|
|
}
|
|
}
|
|
|
|
func TestSocksMigrationIdempotent(t *testing.T) {
|
|
d := socksNodeData()
|
|
d.Nodes = []*Node{
|
|
{ID: "nod_old", Protocol: "socks5-auth", Status: "active"},
|
|
{ID: "nod_set", Protocol: "socks5-auth", Status: "active", SocksUser: "kept", SocksPassword: "keptpw"},
|
|
{ID: "nod_vless", Protocol: "vless-reality", Status: "active"},
|
|
}
|
|
if !MigrateSocksNodeCredentials(d) {
|
|
t.Fatal("first migration must report a change")
|
|
}
|
|
old := d.Nodes[0]
|
|
if old.SocksUser == "" || old.SocksPassword == "" {
|
|
t.Fatal("legacy socks node must receive a fixed pair")
|
|
}
|
|
if d.Nodes[1].SocksUser != "kept" || d.Nodes[1].SocksPassword != "keptpw" {
|
|
t.Fatal("an existing pair must be preserved")
|
|
}
|
|
if d.Nodes[2].SocksUser != "" {
|
|
t.Fatal("non-socks nodes must not gain an account")
|
|
}
|
|
user, password := old.SocksUser, old.SocksPassword
|
|
if MigrateSocksNodeCredentials(d) {
|
|
t.Fatal("second migration must be a no-op")
|
|
}
|
|
if old.SocksUser != user || old.SocksPassword != password {
|
|
t.Fatal("migration must be idempotent")
|
|
}
|
|
}
|
|
|
|
func TestSocksMigrationRunsOnStoreLoad(t *testing.T) {
|
|
dir := t.TempDir()
|
|
file := filepath.Join(dir, "data.json")
|
|
d := socksNodeData()
|
|
seed := EmptyData()
|
|
seed.Servers = d.Servers
|
|
seed.Customers = d.Customers
|
|
seed.Nodes = []*Node{{ID: "nod_old", Protocol: "socks5-auth", Status: "active"}}
|
|
raw, err := marshalJSON(seed)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(file, append(raw, '\n'), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store := NewStore(file)
|
|
if err := store.Init(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
node := store.Data.Nodes[0]
|
|
if node.SocksUser == "" || node.SocksPassword == "" {
|
|
t.Fatal("store load must migrate legacy socks nodes")
|
|
}
|
|
// And the migration is persisted, not just in memory.
|
|
reloaded := NewStore(file)
|
|
if err := reloaded.Init(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if reloaded.Data.Nodes[0].SocksUser != node.SocksUser {
|
|
t.Fatal("migrated pair must be persisted to disk")
|
|
}
|
|
}
|
|
|
|
func TestZeroClientDeployNotGated(t *testing.T) {
|
|
d := socksNodeData()
|
|
vless := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23456,
|
|
Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}}
|
|
socks := &Node{ID: "nod_s", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct", Status: "draft", Port: 23457}
|
|
d.Nodes = []*Node{vless, socks}
|
|
for _, node := range d.Nodes {
|
|
if err := ValidateNodeDeploy(d, node); err != nil {
|
|
t.Fatalf("%s zero-client deploy must be allowed: %v", node.Protocol, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestZeroClientRebuildKeepsNode(t *testing.T) {
|
|
d := socksNodeData()
|
|
node := &Node{ID: "nod_v", Protocol: "vless-reality-vision", ServerID: "srv_1", Topology: "direct", Status: "active",
|
|
ResourceID: "res_v", Reality: &RealityConfig{KeyID: "reality-nod_v", ServerName: "www.tesla.com", DestPort: 443}}
|
|
d.Nodes = []*Node{node}
|
|
if err := RebuildNode(d, node, true); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if node.Status == "removing" {
|
|
t.Fatal("zero-client rebuild must not auto-remove the node")
|
|
}
|
|
applies, deletes := 0, 0
|
|
for _, job := range d.Jobs {
|
|
if job.NodeID == node.ID && job.Action == "apply_resource" {
|
|
applies++
|
|
}
|
|
if job.NodeID == node.ID && job.Action == "delete_resource" {
|
|
deletes++
|
|
}
|
|
}
|
|
if applies != 1 || deletes != 0 {
|
|
t.Fatalf("jobs: applies=%d deletes=%d, want 1/0", applies, deletes)
|
|
}
|
|
}
|
|
|
|
func TestSocksSubscriptionCarriesFixedAccount(t *testing.T) {
|
|
d := socksNodeData()
|
|
node := &Node{ID: "nod_s", Name: "Socks", Protocol: "socks5-auth", ServerID: "srv_1", Topology: "direct",
|
|
NetworkMode: "ipv4", Port: 23462, Status: "active", SocksUser: "fixeduser", SocksPassword: "fixedpass"}
|
|
d.Nodes = []*Node{node}
|
|
for _, customerID := range []string{"cus_1", "cus_2"} {
|
|
nc := &NodeClient{NodeID: node.ID, CustomerID: customerID, Status: "active"}
|
|
uri := NodeClientURI(d, nc)
|
|
if !strings.HasPrefix(uri, "socks5://fixeduser:fixedpass@entry.example.com:23462") {
|
|
t.Fatalf("%s: uri = %q, want the fixed account", customerID, uri)
|
|
}
|
|
}
|
|
}
|