From 3d51aa455006903345f554a2dd90034993796114 Mon Sep 17 00:00:00 2001 From: sergei Date: Tue, 14 Apr 2026 06:23:55 +0400 Subject: vpnem: VPN infrastructure with load-balanced multi-protocol nodes - Multi-protocol VPS nodes (VLESS-REALITY + Hysteria2 + SOCKS5) - Smart load balancing via recommendation API - Windows/Linux client (Go + Wails + sing-box) - Server API with RealIP detection and connection tracking - Auto-deployment via vpnui control plane - Silent Windows installer with UAC elevation - Load-based server recommendation (no sticky sessions) - Best Server one-click connection workflow --- internal/api/control_test.go | 297 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 internal/api/control_test.go (limited to 'internal/api/control_test.go') diff --git a/internal/api/control_test.go b/internal/api/control_test.go new file mode 100644 index 0000000..336aa52 --- /dev/null +++ b/internal/api/control_test.go @@ -0,0 +1,297 @@ +package api + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/control" + "vpnem/internal/models" + "vpnem/internal/rules" +) + +func TestCanPublishNodeState(t *testing.T) { + tests := []struct { + name string + state control.NodeState + want bool + }{ + {name: "healthy", state: control.NodeState{BootstrapStatus: "healthy"}, want: true}, + {name: "ready", state: control.NodeState{BootstrapStatus: "ready"}, want: true}, + {name: "planned", state: control.NodeState{BootstrapStatus: "planned"}, want: false}, + {name: "failed", state: control.NodeState{BootstrapStatus: "failed"}, want: false}, + {name: "unreachable", state: control.NodeState{BootstrapStatus: "unreachable"}, want: false}, + {name: "healthy services", state: control.NodeState{ + BootstrapStatus: "healthy", + Services: []control.ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 200}, + }, want: true}, + {name: "degraded services", state: control.NodeState{ + BootstrapStatus: "healthy", + Services: []control.ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 503}, + }, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := canPublishNodeState(tt.state) + if got != tt.want { + t.Fatalf("canPublishNodeState(%+v) = %v, want %v", tt.state, got, tt.want) + } + }) + } +} + +func setupControlTestStore(t *testing.T) *rules.Store { + t.Helper() + dir := t.TempDir() + + writeJSON := func(name string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + + writeJSON("servers.json", models.ServersResponse{Servers: []models.Server{}}) + writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}}) + writeJSON("version.json", models.VersionResponse{Version: "test"}) + writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"}) + + if err := os.MkdirAll(filepath.Join(dir, "control", "inventory"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "control", "state"), 0o755); err != nil { + t.Fatal(err) + } + + return rules.NewStore(dir) +} + +func TestBuildQuickProvisionNode(t *testing.T) { + node, password, err := buildQuickProvisionNode(quickProvisionRequest{ + Host: "89.124.96.166", + RootPassword: "secret", + EnableMulti: true, + EnableSocks: true, + }) + if err != nil { + t.Fatalf("buildQuickProvisionNode() error = %v", err) + } + if password != "secret" { + t.Fatalf("password = %q, want secret", password) + } + if node.Host != "89.124.96.166" { + t.Fatalf("node.Host = %q", node.Host) + } + if !strings.Contains(node.Name, "Multi") { + t.Fatalf("node.Name = %q, want generated multi-style name", node.Name) + } + if node.SSH.Auth != "password" { + t.Fatalf("node.SSH.Auth = %q, want password", node.SSH.Auth) + } + if node.SSH.PasswordEnv == "" { + t.Fatal("node.SSH.PasswordEnv should be set for persisted quick-provision nodes") + } + if node.SSH.Password != "secret" { + t.Fatalf("node.SSH.Password mismatch") + } + if len(node.Protocols) != 3 { + t.Fatalf("expected 3 protocols, got %d", len(node.Protocols)) + } + seen := map[string]int{} + for _, protocol := range node.Protocols { + seen[protocol.Type] = protocol.Port + } + if seen["vless-reality"] != 443 { + t.Fatalf("vless-reality port = %d, want 443", seen["vless-reality"]) + } + if seen["hysteria2"] != 443 { + t.Fatalf("hysteria2 port = %d, want 443", seen["hysteria2"]) + } + if seen["socks5"] != 54101 { + t.Fatalf("socks5 port = %d, want 54101", seen["socks5"]) + } +} + +func TestBuildQuickProvisionNodeReality(t *testing.T) { + node, _, err := buildQuickProvisionNode(quickProvisionRequest{ + Host: "89.124.96.166", + RootPassword: "secret", + EnableReality: true, + }) + if err != nil { + t.Fatalf("buildQuickProvisionNode() error = %v", err) + } + if len(node.Protocols) != 1 { + t.Fatalf("expected 1 protocol, got %d", len(node.Protocols)) + } + if node.Protocols[0].Type != "vless-reality" { + t.Fatalf("protocol type = %q, want vless-reality", node.Protocols[0].Type) + } + if node.Protocols[0].Reality == nil || node.Protocols[0].Reality.ServerName == "" { + t.Fatal("expected reality defaults to be set") + } +} + +func TestNodeNeedsProvisionedDNS(t *testing.T) { + realityOnly := control.Node{ + Protocols: []control.ProtocolProfile{ + {Type: "vless-reality", Enabled: true, Port: 443}, + }, + } + if nodeNeedsProvisionedDNS(realityOnly) { + t.Fatal("did not expect DNS requirement for vless-reality-only node") + } + + wsNode := control.Node{ + Protocols: []control.ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{Enabled: true}}, + }, + } + if !nodeNeedsProvisionedDNS(wsNode) { + t.Fatal("expected DNS requirement for tls-enabled vless node") + } +} + +func TestVPNUIIncludesReinstallActions(t *testing.T) { + if !strings.Contains(vpnUIHTML, "Начать установку") { + t.Fatal("expected installer-style quick action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Открыть тонкую настройку") { + t.Fatal("expected advanced jump action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Быстрая установка") { + t.Fatal("expected installer-like quick install heading in vpnui") + } + if !strings.Contains(vpnUIHTML, "Тонкая настройка и сервисные действия") { + t.Fatal("expected unified advanced section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Что заполнится автоматически") { + t.Fatal("expected auto defaults explanation in vpnui") + } + if !strings.Contains(vpnUIHTML, "Починить сервер") { + t.Fatal("expected russian repair action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Переустановить сервер") { + t.Fatal("expected russian reinstall action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Проверить VPS") { + t.Fatal("expected russian inspect vps action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Добавить SOCKS5") { + t.Fatal("expected russian Add SOCKS5 action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Удалить сервер") { + t.Fatal("expected russian delete server action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Основные действия") { + t.Fatal("expected russian primary actions section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Ручные переопределения протоколов") { + t.Fatal("expected russian operator protocol overrides section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Что можно сделать здесь") { + t.Fatal("expected russian guide in vpnui") + } + if !strings.Contains(vpnUIHTML, "Выберите узел, чтобы увидеть самый безопасный следующий шаг.") { + t.Fatal("expected russian node guide placeholder in vpnui") + } + if !strings.Contains(vpnUIHTML, "Можно ставить MULTI") { + t.Fatal("expected russian quick status rail labels in vpnui") + } + if !strings.Contains(vpnUIHTML, "Готов к публикации") { + t.Fatal("expected russian node status rail labels in vpnui") + } + if !strings.Contains(vpnUIHTML, "data-fleet-filter=\"ready\"") { + t.Fatal("expected node fleet filters in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сейчас ни один узел не подходит под этот фильтр.") { + t.Fatal("expected russian filtered empty state in vpnui") + } + if !strings.Contains(vpnUIHTML, "Копировать URI") { + t.Fatal("expected russian copy uri action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Копировать детали") { + t.Fatal("expected russian copy details action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сейчас в системе") { + t.Fatal("expected simplified current system summary in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сервер работает") { + t.Fatal("expected product-oriented node card language in vpnui") + } +} + +func TestFindNodeByHost(t *testing.T) { + store := setupControlTestStore(t) + handler := &Handler{store: store} + + if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ + ID: "nl-01", + Name: "NL 01", + Provider: "custom-vps", + Region: "nl", + Host: "89.124.96.166", + Enabled: true, + SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []control.ProtocolProfile{ + {Type: "socks5", Enabled: true, Port: 54101}, + }, + }); err != nil { + t.Fatal(err) + } + + node, err := handler.findNodeByHost("89.124.96.166") + if err != nil { + t.Fatalf("findNodeByHost() error = %v", err) + } + if node == nil || node.ID != "nl-01" { + t.Fatalf("findNodeByHost() = %+v, want nl-01", node) + } +} + +func TestBuildQuickPreflightResponse(t *testing.T) { + resp := buildQuickPreflightResponse("89.124.96.166", map[string]string{ + "OS_ID": "ubuntu", + "OS_PRETTY": "Ubuntu 24.04 LTS", + "MANAGED": "0", + "DOCKER": "1", + "COMPOSE": "1", + "TCP_443": "0", + "UDP_443": "1", + "TCP_54101": "0", + }) + + if resp.SupportTier != "recommended" { + t.Fatalf("SupportTier = %q, want recommended", resp.SupportTier) + } + if resp.QuickMulti.Supported { + t.Fatal("expected quick multi to be blocked by busy UDP 443") + } + if resp.QuickSocks5.Supported != true { + t.Fatal("expected quick socks5 to stay supported") + } + if got := resp.Ports["udp_443"]; got != "busy" { + t.Fatalf("udp_443 = %q, want busy", got) + } + if len(resp.Capabilities) < 2 || resp.Capabilities[0] != "Можно ставить SOCKS5" || resp.Capabilities[1] != "Конфликт портов для MULTI" { + t.Fatalf("Capabilities = %v, want russian socks5 + multi conflict labels", resp.Capabilities) + } + if resp.HostStateLabel != "Можно поставить SOCKS5" { + t.Fatalf("HostStateLabel = %q, want Russian SOCKS5-only state", resp.HostStateLabel) + } + if resp.SuggestedMultiName == "" || resp.SuggestedSocksName == "" { + t.Fatalf("expected suggested names to be generated, got multi=%q socks=%q", resp.SuggestedMultiName, resp.SuggestedSocksName) + } + if !strings.Contains(resp.RecommendedAction, "SOCKS5") { + t.Fatalf("RecommendedAction = %q, want SOCKS5 hint", resp.RecommendedAction) + } +} -- cgit v1.2.3