summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorsergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
committersergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
commit3d51aa455006903345f554a2dd90034993796114 (patch)
tree62a7be2faf047f5eb7886feebc3b815556f03d7f /internal
downloadvpnem-3d51aa455006903345f554a2dd90034993796114.tar.gz
vpnem-3d51aa455006903345f554a2dd90034993796114.tar.bz2
vpnem-3d51aa455006903345f554a2dd90034993796114.zip
vpnem: VPN infrastructure with load-balanced multi-protocol nodesHEADmain
- 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
Diffstat (limited to 'internal')
-rw-r--r--internal/api/control.go3781
-rw-r--r--internal/api/control_test.go297
-rw-r--r--internal/api/handlers.go345
-rw-r--r--internal/api/handlers_test.go592
-rw-r--r--internal/api/middleware.go70
-rw-r--r--internal/api/recommend_test.go549
-rw-r--r--internal/api/router.go80
-rw-r--r--internal/api/subscribe.go288
-rw-r--r--internal/config/builder.go340
-rw-r--r--internal/config/builder_test.go431
-rw-r--r--internal/config/bypass.go169
-rw-r--r--internal/config/modes.go176
-rw-r--r--internal/config/outbounds.go212
-rw-r--r--internal/config/policy.go102
-rw-r--r--internal/control/bootstrap.go369
-rw-r--r--internal/control/bootstrap_test.go58
-rw-r--r--internal/control/catalog.go229
-rw-r--r--internal/control/catalog_test.go332
-rw-r--r--internal/control/dns.go163
-rw-r--r--internal/control/dns_test.go58
-rw-r--r--internal/control/health_test.go49
-rw-r--r--internal/control/hysteria2.go179
-rw-r--r--internal/control/inventory.go225
-rw-r--r--internal/control/inventory_test.go50
-rw-r--r--internal/control/lifecycle.go205
-rw-r--r--internal/control/lifecycle_test.go149
-rw-r--r--internal/control/models.go66
-rw-r--r--internal/control/preflight.go68
-rw-r--r--internal/control/publish.go321
-rw-r--r--internal/control/reality.go64
-rw-r--r--internal/control/runtime.go586
-rw-r--r--internal/control/runtime_test.go307
-rw-r--r--internal/control/ssh.go182
-rw-r--r--internal/control/ssh_test.go80
-rw-r--r--internal/control/state.go71
-rw-r--r--internal/control/upgrade_test.go55
-rw-r--r--internal/engine/engine.go138
-rw-r--r--internal/engine/healthcheck.go63
-rw-r--r--internal/engine/healthcheck_test.go38
-rw-r--r--internal/engine/httpclient.go45
-rw-r--r--internal/engine/logger.go62
-rw-r--r--internal/engine/proxy_port.go37
-rw-r--r--internal/engine/watchdog.go147
-rw-r--r--internal/models/catalog.go35
-rw-r--r--internal/models/client.go59
-rw-r--r--internal/models/policy.go23
-rw-r--r--internal/models/ruleset.go23
-rw-r--r--internal/models/server.go46
-rw-r--r--internal/rules/connections.go336
-rw-r--r--internal/rules/connections_test.go201
-rw-r--r--internal/rules/loader.go210
-rw-r--r--internal/state/state.go174
-rw-r--r--internal/sync/fetcher.go643
-rw-r--r--internal/sync/fetcher_test.go300
-rw-r--r--internal/sync/health.go33
-rw-r--r--internal/sync/latency.go62
-rw-r--r--internal/sync/updater.go180
57 files changed, 14153 insertions, 0 deletions
diff --git a/internal/api/control.go b/internal/api/control.go
new file mode 100644
index 0000000..2849f02
--- /dev/null
+++ b/internal/api/control.go
@@ -0,0 +1,3781 @@
+package api
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "vpnem/internal/control"
+)
+
+func (h *Handler) ControlNodes(w http.ResponseWriter, r *http.Request) {
+ if !h.authorizeAdmin(w, r) {
+ return
+ }
+
+ inventory, err := control.LoadInventoryDir(h.inventoryDir())
+ if err != nil {
+ if control.IsNotExist(err) {
+ writeJSON(w, map[string]any{"nodes": []control.Node{}})
+ return
+ }
+ log.Printf("error loading control inventory: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ states := make(map[string]*control.NodeState, len(inventory.Nodes))
+ for _, node := range inventory.Nodes {
+ state, err := control.LoadNodeState(h.stateDir(), node.ID)
+ if err != nil {
+ if control.IsNotExist(err) {
+ continue
+ }
+ log.Printf("error loading node state for %s: %v", node.ID, err)
+ continue
+ }
+ states[node.ID] = state
+ }
+ decisions := control.PublishDecisions(inventory.Nodes, states)
+
+ writeJSON(w, map[string]any{
+ "nodes": inventory.Nodes,
+ "states": states,
+ "publish_decisions": decisions,
+ })
+}
+
+type actionRequest struct {
+ SSHPassword string `json:"ssh_password"`
+}
+
+type quickProvisionRequest struct {
+ Host string `json:"host"`
+ RootPassword string `json:"root_password"`
+ Region string `json:"region"`
+ Provider string `json:"provider"`
+ ACMEEmail string `json:"acme_email"`
+ EnableMulti bool `json:"enable_multi"`
+ EnableVLESS bool `json:"enable_vless"`
+ EnableReality bool `json:"enable_vless_reality"`
+ EnableSS bool `json:"enable_shadowsocks"`
+ EnableSocks bool `json:"enable_socks5"`
+ EnableVMess bool `json:"enable_vmess"`
+ EnableHY2 bool `json:"enable_hysteria2"`
+}
+
+type quickPreflightDecision struct {
+ Supported bool `json:"supported"`
+ Reasons []string `json:"reasons,omitempty"`
+}
+
+type quickPreflightResponse struct {
+ Host string `json:"host"`
+ HostStateLabel string `json:"host_state_label,omitempty"`
+ SuggestedMultiName string `json:"suggested_multi_name,omitempty"`
+ SuggestedSocksName string `json:"suggested_socks_name,omitempty"`
+ OSID string `json:"os_id,omitempty"`
+ OSPretty string `json:"os_pretty,omitempty"`
+ OSLike string `json:"os_like,omitempty"`
+ SupportTier string `json:"support_tier"`
+ AlreadyManaged bool `json:"already_managed"`
+ DockerInstalled bool `json:"docker_installed"`
+ ComposeAvailable bool `json:"compose_available"`
+ Ports map[string]string `json:"ports"`
+ Capabilities []string `json:"capabilities,omitempty"`
+ RecommendedAction string `json:"recommended_action,omitempty"`
+ QuickMulti quickPreflightDecision `json:"quick_multi"`
+ QuickSocks5 quickPreflightDecision `json:"quick_socks5"`
+ Warnings []string `json:"warnings,omitempty"`
+}
+
+func (h *Handler) ControlNodeByID(w http.ResponseWriter, r *http.Request) {
+ if !h.authorizeAdmin(w, r) {
+ return
+ }
+
+ nodeID := strings.TrimPrefix(r.URL.Path, "/api/v1/control/nodes/")
+ nodeID = strings.TrimSuffix(nodeID, "/")
+ if nodeID == "" {
+ http.Error(w, "node id required", http.StatusBadRequest)
+ return
+ }
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ writeJSON(w, node)
+}
+
+func (h *Handler) ControlNodeAction(w http.ResponseWriter, r *http.Request) {
+ if !h.authorizeAdmin(w, r) {
+ return
+ }
+
+ path := strings.TrimPrefix(r.URL.Path, "/api/v1/control/nodes/")
+ path = strings.TrimSuffix(path, "/")
+ if path == "" {
+ http.Error(w, "node id required", http.StatusBadRequest)
+ return
+ }
+
+ switch {
+ case strings.HasSuffix(path, "/state") && r.Method == http.MethodGet:
+ nodeID := strings.TrimSuffix(path, "/state")
+ h.ControlNodeState(w, r, nodeID)
+ case strings.HasSuffix(path, "/provision") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/provision")
+ h.ControlNodeProvision(w, r, nodeID)
+ case strings.HasSuffix(path, "/provision-dns") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/provision-dns")
+ h.ControlNodeProvisionDNS(w, r, nodeID)
+ case strings.HasSuffix(path, "/delete-dns") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/delete-dns")
+ h.ControlNodeDeleteDNS(w, r, nodeID)
+ case strings.HasSuffix(path, "/bootstrap") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/bootstrap")
+ h.ControlNodeBootstrap(w, r, nodeID)
+ case strings.HasSuffix(path, "/check") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/check")
+ h.ControlNodeCheck(w, r, nodeID)
+ case strings.HasSuffix(path, "/upgrade") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/upgrade")
+ h.ControlNodeUpgrade(w, r, nodeID)
+ case strings.HasSuffix(path, "/add-socks5") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/add-socks5")
+ h.ControlNodeAddSocks5(w, r, nodeID)
+ case strings.HasSuffix(path, "/repair-reinstall") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/repair-reinstall")
+ h.ControlNodeRepairReinstall(w, r, nodeID)
+ case strings.HasSuffix(path, "/clean-reinstall") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/clean-reinstall")
+ h.ControlNodeCleanReinstall(w, r, nodeID)
+ case strings.HasSuffix(path, "/enable") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/enable")
+ h.ControlNodeEnable(w, r, nodeID)
+ case strings.HasSuffix(path, "/disable") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/disable")
+ h.ControlNodeDisable(w, r, nodeID)
+ case strings.HasSuffix(path, "/rotate-secrets") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/rotate-secrets")
+ h.ControlNodeRotateSecrets(w, r, nodeID)
+ case strings.HasSuffix(path, "/destroy") && r.Method == http.MethodPost:
+ nodeID := strings.TrimSuffix(path, "/destroy")
+ h.ControlNodeDestroy(w, r, nodeID)
+ case r.Method == http.MethodDelete:
+ h.DeleteControlNode(w, r, path)
+ case r.Method == http.MethodGet:
+ h.ControlNodeByID(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+}
+
+func (h *Handler) UpsertControlNode(w http.ResponseWriter, r *http.Request) {
+ if !h.authorizeAdmin(w, r) {
+ return
+ }
+
+ var node control.Node
+ if err := json.NewDecoder(r.Body).Decode(&node); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+
+ if node.SSH.Port == 0 {
+ node.SSH.Port = 22
+ }
+ if err := validateNodeForUI(node); err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+
+ path, err := control.SaveNodeFile(h.inventoryDir(), node)
+ if err != nil {
+ log.Printf("error saving control node: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ savedNode, err := control.LoadNodeFile(path)
+ if err != nil {
+ log.Printf("error reloading saved control node: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "saved": true,
+ "path": path,
+ "node": savedNode,
+ })
+}
+
+func (h *Handler) QuickProvisionControlNode(w http.ResponseWriter, r *http.Request) {
+ if !h.authorizeAdmin(w, r) {
+ return
+ }
+
+ var req quickProvisionRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+
+ node, sshPassword, err := buildQuickProvisionNode(req)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ if existing, err := h.findNodeByHost(node.Host); err != nil {
+ log.Printf("error checking quick-provision host conflicts: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ } else if existing != nil {
+ http.Error(w, "этот host уже используется узлом "+existing.ID+"; откройте его в настройках и используйте обновление или переустановку вместо создания второго quick-узла", http.StatusConflict)
+ return
+ }
+
+ result, err := h.provisionNodeFlow(r.Context(), &node, sshPassword)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ result["node"] = node
+ writeJSON(w, result)
+}
+
+func (h *Handler) QuickPreflightControlNode(w http.ResponseWriter, r *http.Request) {
+ if !h.authorizeAdmin(w, r) {
+ return
+ }
+
+ var req quickProvisionRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ host := strings.TrimSpace(req.Host)
+ password := strings.TrimSpace(req.RootPassword)
+ if host == "" || password == "" {
+ http.Error(w, "host and root_password are required", http.StatusBadRequest)
+ return
+ }
+
+ node := control.Node{
+ ID: "quick-preflight",
+ Name: "Quick Preflight",
+ Provider: strings.TrimSpace(req.Provider),
+ Region: strings.TrimSpace(req.Region),
+ Host: host,
+ Enabled: true,
+ SSH: control.SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "password",
+ Password: password,
+ },
+ }
+ result, err := control.SSHRunner{}.Run(r.Context(), node, control.RenderPreflightInspectScript())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ data := control.ParsePreflightInspectOutput(result.Stdout)
+ resp := buildQuickPreflightResponse(host, data)
+ writeJSON(w, resp)
+}
+
+func (h *Handler) DeleteControlNode(w http.ResponseWriter, r *http.Request, nodeID string) {
+ if nodeID == "" {
+ http.Error(w, "node id required", http.StatusBadRequest)
+ return
+ }
+
+ if err := control.DeleteNodeFile(h.inventoryDir(), nodeID); err != nil {
+ log.Printf("error deleting control node: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if err := control.DeleteNodeState(h.stateDir(), nodeID); err != nil {
+ log.Printf("error deleting node state: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "deleted": true,
+ "node_id": nodeID,
+ })
+}
+
+func (h *Handler) PublishControlCatalog(w http.ResponseWriter, r *http.Request) {
+ if !h.authorizeAdmin(w, r) {
+ return
+ }
+
+ count, target, catalogTarget, decisions, err := h.publishCurrentCatalog()
+ if err != nil {
+ log.Printf("error publishing control catalog: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "published": true,
+ "target": target,
+ "catalog_v2_target": catalogTarget,
+ "count": count,
+ "publish_decisions": decisions,
+ })
+}
+
+func (h *Handler) publishCurrentCatalog() (int, string, string, map[string]control.PublishDecision, error) {
+ inventory, err := control.LoadInventoryDir(h.inventoryDir())
+ if err != nil {
+ return 0, "", "", nil, err
+ }
+
+ states := make(map[string]*control.NodeState, len(inventory.Nodes))
+ for _, node := range inventory.Nodes {
+ state, err := control.LoadNodeState(h.stateDir(), node.ID)
+ if err != nil {
+ if control.IsNotExist(err) {
+ continue
+ }
+ log.Printf("error loading node state for publish %s: %v", node.ID, err)
+ continue
+ }
+ states[node.ID] = state
+ }
+
+ publishable := control.PublishableNodes(inventory.Nodes, states)
+ decisions := control.PublishDecisions(inventory.Nodes, states)
+ target := filepath.Join(h.store.DataDir(), "servers.json")
+ if err := control.WriteLegacyCatalog(target, publishable); err != nil {
+ return 0, "", "", nil, err
+ }
+ catalogTarget := filepath.Join(h.store.DataDir(), "catalog-v2.json")
+ if err := control.WriteCatalogV2(catalogTarget, publishable, states); err != nil {
+ return 0, "", "", nil, err
+ }
+ return len(publishable), target, catalogTarget, decisions, nil
+}
+
+func (h *Handler) ControlNodeState(w http.ResponseWriter, r *http.Request, nodeID string) {
+ state, err := control.LoadNodeState(h.stateDir(), nodeID)
+ if err != nil {
+ if control.IsNotExist(err) {
+ http.NotFound(w, r)
+ return
+ }
+ log.Printf("error loading node state: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, state)
+}
+
+func (h *Handler) ControlNodeBootstrap(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for bootstrap: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+ dryRun := r.URL.Query().Get("dry_run") != "false"
+ state, err := control.BootstrapNode(context.Background(), control.SSHRunner{}, *node, control.BootstrapOptions{
+ StateDir: h.stateDir(),
+ DryRun: dryRun,
+ })
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ writeJSON(w, state)
+}
+
+func (h *Handler) ControlNodeCheck(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for check: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+ state, err := control.CheckNode(context.Background(), control.SSHRunner{}, *node, h.stateDir())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ writeJSON(w, state)
+}
+
+func (h *Handler) ControlNodeUpgrade(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for upgrade: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+ state, err := control.UpgradeNode(context.Background(), control.SSHRunner{}, *node, h.stateDir())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ writeJSON(w, state)
+}
+
+func (h *Handler) ControlNodeAddSocks5(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for add socks5: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+
+ inspect, err := control.SSHRunner{}.Run(r.Context(), *node, control.RenderPreflightInspectScript())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ preflight := control.ParsePreflightInspectOutput(inspect.Stdout)
+ if portStatusValue(preflight["TCP_54101"]) == "busy" {
+ http.Error(w, "tcp/54101 уже занят на этом VPS; безопасно добавить SOCKS5 нельзя", http.StatusConflict)
+ return
+ }
+
+ updated, err := control.AddSocks5Protocol(*node, 54101)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusConflict)
+ return
+ }
+ if _, err := control.SaveNodeFile(h.inventoryDir(), updated); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ updated.SSH.Password = node.SSH.Password
+ state, err := control.UpgradeNode(context.Background(), control.SSHRunner{}, updated, h.stateDir())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ response := map[string]any{
+ "added": true,
+ "protocol": "socks5",
+ "node": updated,
+ "state": state,
+ "published": false,
+ "recommended_next": "SOCKS5 was added and the node runtime was upgraded.",
+ }
+ if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil {
+ response["published"] = true
+ response["target"] = target
+ response["catalog_v2_target"] = catalogTarget
+ response["count"] = count
+ response["publish_decisions"] = decisions
+ }
+ writeJSON(w, response)
+}
+
+func (h *Handler) ControlNodeRepairReinstall(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for repair reinstall: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+ state, err := control.RepairReinstallNode(context.Background(), control.SSHRunner{}, *node, h.stateDir())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ response := map[string]any{
+ "reinstalled": true,
+ "reinstall_mode": "repair",
+ "node": node,
+ "state": state,
+ "published": false,
+ "publish_decisions": map[string]control.PublishDecision{},
+ }
+ if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil {
+ response["published"] = true
+ response["target"] = target
+ response["catalog_v2_target"] = catalogTarget
+ response["count"] = count
+ response["publish_decisions"] = decisions
+ }
+ writeJSON(w, response)
+}
+
+func (h *Handler) ControlNodeCleanReinstall(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for clean reinstall: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+ rotated, err := control.RotateNodeSecrets(*node)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if _, err := control.SaveNodeFile(h.inventoryDir(), rotated); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ rotated.SSH.Password = node.SSH.Password
+ state, err := control.CleanReinstallNode(context.Background(), control.SSHRunner{}, rotated, h.stateDir())
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ response := map[string]any{
+ "reinstalled": true,
+ "reinstall_mode": "clean",
+ "rotated": true,
+ "node": rotated,
+ "state": state,
+ "published": false,
+ }
+ if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil {
+ response["published"] = true
+ response["target"] = target
+ response["catalog_v2_target"] = catalogTarget
+ response["count"] = count
+ response["publish_decisions"] = decisions
+ }
+ writeJSON(w, response)
+}
+
+func (h *Handler) ControlNodeEnable(w http.ResponseWriter, r *http.Request, nodeID string) {
+ h.updateNodeEnabled(w, nodeID, true)
+}
+
+func (h *Handler) ControlNodeDisable(w http.ResponseWriter, r *http.Request, nodeID string) {
+ h.updateNodeEnabled(w, nodeID, false)
+}
+
+func (h *Handler) updateNodeEnabled(w http.ResponseWriter, nodeID string, enabled bool) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for enable toggle: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, nil)
+ return
+ }
+
+ updated := control.SetNodeEnabled(*node, enabled)
+ if _, err := control.SaveNodeFile(h.inventoryDir(), updated); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ count, target, catalogTarget, decisions, err := h.publishCurrentCatalog()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "saved": true,
+ "enabled": enabled,
+ "node": updated,
+ "published": true,
+ "target": target,
+ "catalog_v2_target": catalogTarget,
+ "count": count,
+ "publish_decisions": decisions,
+ })
+}
+
+func (h *Handler) ControlNodeRotateSecrets(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for secret rotation: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ rotated, err := control.RotateNodeSecrets(*node)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if _, err := control.SaveNodeFile(h.inventoryDir(), rotated); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "rotated": true,
+ "node": rotated,
+ })
+}
+
+func (h *Handler) ControlNodeDestroy(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for destroy: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+
+ var dnsClient control.DNSProvider
+ if strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")) != "" && strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")) != "" {
+ dnsClient = control.PorkbunClient{
+ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")),
+ SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")),
+ }
+ }
+
+ warnings := control.DestroyNode(r.Context(), control.SSHRunner{}, dnsClient, "em-sysadmin.xyz", *node, h.inventoryDir(), h.stateDir())
+ count, target, catalogTarget, decisions, err := h.publishCurrentCatalog()
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "destroyed": true,
+ "node_id": nodeID,
+ "warnings": warnings,
+ "published": true,
+ "target": target,
+ "catalog_v2_target": catalogTarget,
+ "count": count,
+ "publish_decisions": decisions,
+ })
+}
+
+func (h *Handler) ControlNodeProvision(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for full provision: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ req, err := decodeActionRequest(r)
+ if err != nil {
+ http.Error(w, "invalid json", http.StatusBadRequest)
+ return
+ }
+ node = applyActionPassword(node, req)
+
+ response, err := h.provisionNodeFlow(r.Context(), node, req.SSHPassword)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+ writeJSON(w, response)
+}
+
+func (h *Handler) ControlNodeProvisionDNS(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for dns: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+
+ client := control.PorkbunClient{
+ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")),
+ SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")),
+ }
+ fqdn, err := client.EnsureRandomARecord(context.Background(), "em-sysadmin.xyz", dnsPrefixForNode(*node), strings.TrimSpace(node.Host), 600)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ node.Domain = fqdn
+ if node.Metadata == nil {
+ node.Metadata = map[string]string{}
+ }
+ node.Metadata["dns_zone"] = "em-sysadmin.xyz"
+ node.Metadata["dns_provider"] = "porkbun"
+ if _, err := control.SaveNodeFile(h.inventoryDir(), *node); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ now := time.Now().UTC()
+ state, _ := control.LoadNodeState(h.stateDir(), node.ID)
+ if state == nil {
+ state = &control.NodeState{
+ NodeID: node.ID,
+ PublicHost: fqdn,
+ Services: []control.ServiceStatus{},
+ Metadata: map[string]any{},
+ }
+ }
+ state.PublicHost = fqdn
+ state.LastDNSSyncAt = &now
+ if state.Metadata == nil {
+ state.Metadata = map[string]any{}
+ }
+ state.Metadata["dns_provider"] = "porkbun"
+ state.Metadata["dns_zone"] = "em-sysadmin.xyz"
+ state.Metadata["dns_fqdn"] = fqdn
+ if err := control.SaveNodeState(h.stateDir(), *state); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "provisioned": true,
+ "fqdn": fqdn,
+ "node": node,
+ })
+}
+
+func (h *Handler) ControlNodeDeleteDNS(w http.ResponseWriter, r *http.Request, nodeID string) {
+ node, ok, err := h.loadNode(nodeID)
+ if err != nil {
+ log.Printf("error loading control node for dns delete: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ if strings.TrimSpace(node.Domain) == "" {
+ http.Error(w, "node domain is empty", http.StatusBadRequest)
+ return
+ }
+
+ name := strings.TrimSuffix(node.Domain, ".em-sysadmin.xyz")
+ name = strings.TrimSuffix(name, ".")
+ client := control.PorkbunClient{
+ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")),
+ SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")),
+ }
+ if err := client.DeleteARecord(context.Background(), "em-sysadmin.xyz", name); err != nil {
+ http.Error(w, err.Error(), http.StatusBadGateway)
+ return
+ }
+
+ writeJSON(w, map[string]any{
+ "deleted": true,
+ "domain": node.Domain,
+ })
+}
+
+func (h *Handler) VPNUI(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(vpnUIHTML))
+}
+
+func (h *Handler) inventoryDir() string {
+ return filepath.Join(h.store.DataDir(), "control", "inventory")
+}
+
+func (h *Handler) stateDir() string {
+ return filepath.Join(h.store.DataDir(), "control", "state")
+}
+
+func (h *Handler) loadNode(nodeID string) (*control.Node, bool, error) {
+ inventory, err := control.LoadInventoryDir(h.inventoryDir())
+ if err != nil {
+ return nil, false, err
+ }
+ node, ok := inventory.NodeByID(nodeID)
+ return node, ok, nil
+}
+
+func (h *Handler) findNodeByHost(host string) (*control.Node, error) {
+ inventory, err := control.LoadInventoryDir(h.inventoryDir())
+ if err != nil {
+ if control.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ needle := normalizeHost(host)
+ for idx := range inventory.Nodes {
+ if normalizeHost(inventory.Nodes[idx].Host) == needle {
+ return &inventory.Nodes[idx], nil
+ }
+ }
+ return nil, nil
+}
+
+func buildQuickPreflightResponse(host string, data map[string]string) quickPreflightResponse {
+ resp := quickPreflightResponse{
+ Host: host,
+ SuggestedMultiName: generateQuickNodeName("auto", "multi", host),
+ SuggestedSocksName: generateQuickNodeName("auto", "socks5", host),
+ OSID: data["OS_ID"],
+ OSPretty: data["OS_PRETTY"],
+ OSLike: data["OS_LIKE"],
+ SupportTier: classifySupportTier(data["OS_ID"], data["OS_LIKE"]),
+ AlreadyManaged: data["MANAGED"] == "1",
+ DockerInstalled: data["DOCKER"] == "1",
+ ComposeAvailable: data["COMPOSE"] == "1",
+ Ports: map[string]string{
+ "tcp_443": portStatusValue(data["TCP_443"]),
+ "udp_443": portStatusValue(data["UDP_443"]),
+ "tcp_54101": portStatusValue(data["TCP_54101"]),
+ },
+ }
+
+ resp.QuickMulti = quickPreflightDecision{Supported: true}
+ resp.QuickSocks5 = quickPreflightDecision{Supported: true}
+
+ if resp.AlreadyManaged {
+ reason := "этот VPS уже управляется через vpnem; используйте настройки и действия с узлом вместо создания второго quick-узла"
+ resp.HostStateLabel = "Уже под управлением"
+ resp.QuickMulti = quickPreflightDecision{Supported: false, Reasons: []string{reason}}
+ resp.QuickSocks5 = quickPreflightDecision{Supported: false, Reasons: []string{reason}}
+ resp.Capabilities = append(resp.Capabilities, "Уже под управлением")
+ resp.RecommendedAction = "Откройте существующий узел и используйте «Обновить сервер», «Починить сервер» или «Добавить SOCKS5»."
+ }
+ if resp.Ports["tcp_443"] == "busy" {
+ resp.QuickMulti.Supported = false
+ resp.QuickMulti.Reasons = append(resp.QuickMulti.Reasons, "TCP-порт 443 уже занят")
+ }
+ if resp.Ports["udp_443"] == "busy" {
+ resp.QuickMulti.Supported = false
+ resp.QuickMulti.Reasons = append(resp.QuickMulti.Reasons, "UDP-порт 443 уже занят")
+ }
+ if resp.Ports["tcp_54101"] == "busy" {
+ resp.QuickSocks5.Supported = false
+ resp.QuickSocks5.Reasons = append(resp.QuickSocks5.Reasons, "TCP-порт 54101 уже занят")
+ }
+ if !resp.AlreadyManaged {
+ switch {
+ case resp.QuickMulti.Supported && resp.QuickSocks5.Supported:
+ resp.Capabilities = append(resp.Capabilities, "Можно ставить MULTI", "Можно ставить SOCKS5")
+ resp.RecommendedAction = "Этот VPS выглядит чистым. По умолчанию выбирайте MULTI, если вам не нужен только простой SOCKS5-прокси."
+ case resp.QuickMulti.Supported:
+ resp.Capabilities = append(resp.Capabilities, "Можно ставить MULTI")
+ resp.RecommendedAction = "Этот VPS готов для стандартной установки MULTI."
+ case resp.QuickSocks5.Supported:
+ resp.Capabilities = append(resp.Capabilities, "Можно ставить SOCKS5", "Конфликт портов для MULTI")
+ resp.RecommendedAction = "Сейчас MULTI заблокирован, но SOCKS5 всё ещё безопасно ставить на порт 54101."
+ default:
+ resp.Capabilities = append(resp.Capabilities, "Быстрая установка заблокирована")
+ resp.RecommendedAction = "У этого VPS есть конфликт портов или неподходящее состояние для быстрой установки. Сначала исправьте хост или используйте путь через настройки."
+ }
+ }
+ if resp.SupportTier == "experimental" {
+ resp.Warnings = append(resp.Warnings, "Этот дистрибутив считается экспериментальным. Для наиболее предсказуемой установки рекомендуются Debian или Ubuntu.")
+ }
+ if resp.SupportTier == "unknown" {
+ resp.Warnings = append(resp.Warnings, "Этот дистрибутив неизвестен для vpnui. Установка может сработать, но он не входит в рекомендуемую матрицу поддержки.")
+ }
+ if !resp.DockerInstalled {
+ resp.Warnings = append(resp.Warnings, "Docker ещё не установлен. vpnui попробует установить его во время bootstrap.")
+ }
+ if !resp.ComposeAvailable {
+ resp.Warnings = append(resp.Warnings, "Docker Compose пока недоступен. vpnui попробует установить его или использовать совместимый путь во время bootstrap.")
+ }
+ if resp.HostStateLabel == "" {
+ switch {
+ case resp.QuickMulti.Supported:
+ resp.HostStateLabel = "VPS чистый"
+ case resp.QuickSocks5.Supported:
+ resp.HostStateLabel = "Можно поставить SOCKS5"
+ default:
+ resp.HostStateLabel = "Установка заблокирована"
+ }
+ }
+ return resp
+}
+
+func generateQuickNodeName(region, kind, host string) string {
+ adjectives := []string{"Maple", "Quartz", "Harbor", "Comet", "Cedar", "Nova", "Atlas", "Echo"}
+ area := strings.ToUpper(strings.TrimSpace(region))
+ if area == "" || area == "AUTO" {
+ area = "AUTO"
+ }
+ kindLabel := "Server"
+ switch kind {
+ case "multi":
+ kindLabel = "Multi"
+ case "socks5":
+ kindLabel = "SOCKS5"
+ }
+ word := adjectives[0]
+ if host != "" {
+ sum := 0
+ for _, r := range host {
+ sum += int(r)
+ }
+ word = adjectives[sum%len(adjectives)]
+ }
+ suffix := "01"
+ if part, err := controlRandomHex(1); err == nil && part != "" {
+ suffix = strings.ToUpper(part)
+ }
+ return area + " " + kindLabel + " " + word + " " + suffix
+}
+
+func classifySupportTier(osID, osLike string) string {
+ combined := strings.ToLower(strings.TrimSpace(osID + " " + osLike))
+ switch {
+ case strings.Contains(combined, "debian") || strings.Contains(combined, "ubuntu"):
+ return "recommended"
+ case strings.Contains(combined, "rhel") || strings.Contains(combined, "rocky") || strings.Contains(combined, "alma") || strings.Contains(combined, "centos") || strings.Contains(combined, "fedora"):
+ return "supported"
+ case strings.Contains(combined, "arch") || strings.Contains(combined, "alpine"):
+ return "experimental"
+ default:
+ return "unknown"
+ }
+}
+
+func portStatusValue(value string) string {
+ switch strings.ToLower(strings.TrimSpace(value)) {
+ case "1", "busy":
+ return "busy"
+ case "0", "free":
+ return "free"
+ default:
+ return "unknown"
+ }
+}
+
+func (h *Handler) authorizeAdmin(w http.ResponseWriter, r *http.Request) bool {
+ token := strings.TrimSpace(os.Getenv("VPNEM_ADMIN_TOKEN"))
+ if token == "" {
+ return true
+ }
+
+ provided := strings.TrimSpace(r.Header.Get("X-Admin-Token"))
+ if provided == "" {
+ provided = strings.TrimSpace(r.URL.Query().Get("token"))
+ }
+ if provided != token {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return false
+ }
+ return true
+}
+
+func validateNodeForUI(node control.Node) error {
+ for idx := range node.Protocols {
+ if err := control.EnsureProtocolForUI(&node.Protocols[idx]); err != nil {
+ return err
+ }
+ }
+ if err := control.ValidateNode(node); err != nil {
+ return err
+ }
+
+ for _, protocol := range node.Protocols {
+ switch protocol.Type {
+ case "vless":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return errors.New("vless protocol requires auth.uuid")
+ }
+ case "vless-reality":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return errors.New("vless-reality protocol requires auth.uuid")
+ }
+ if protocol.Reality == nil || strings.TrimSpace(protocol.Reality.ServerName) == "" {
+ return errors.New("vless-reality protocol requires reality.server_name")
+ }
+ case "vmess":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return errors.New("vmess protocol requires auth.uuid")
+ }
+ case "shadowsocks":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return errors.New("shadowsocks protocol requires auth.method and auth.password")
+ }
+ case "hysteria2":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return errors.New("hysteria2 protocol requires auth.password")
+ }
+ }
+ }
+ return nil
+}
+
+func decodeActionRequest(r *http.Request) (actionRequest, error) {
+ var req actionRequest
+ if r.Body == nil || r.ContentLength == 0 {
+ return req, nil
+ }
+ err := json.NewDecoder(r.Body).Decode(&req)
+ if errors.Is(err, http.ErrBodyReadAfterClose) {
+ return req, nil
+ }
+ return req, err
+}
+
+func applyActionPassword(node *control.Node, req actionRequest) *control.Node {
+ if node == nil {
+ return nil
+ }
+ copy := *node
+ copy.SSH.Password = strings.TrimSpace(req.SSHPassword)
+ return &copy
+}
+
+func buildQuickProvisionNode(req quickProvisionRequest) (control.Node, string, error) {
+ host := strings.TrimSpace(req.Host)
+ password := strings.TrimSpace(req.RootPassword)
+ if host == "" {
+ return control.Node{}, "", errors.New("host is required")
+ }
+ if password == "" {
+ return control.Node{}, "", errors.New("root_password is required")
+ }
+
+ region := strings.TrimSpace(req.Region)
+ if region == "" {
+ region = "auto"
+ }
+ provider := strings.TrimSpace(req.Provider)
+ if provider == "" {
+ provider = "custom-vps"
+ }
+ acmeEmail := strings.TrimSpace(req.ACMEEmail)
+ if acmeEmail == "" {
+ acmeEmail = "admin@em-sysadmin.xyz"
+ }
+ enableMulti := req.EnableMulti || (req.EnableReality && req.EnableHY2)
+ nodeKind := "server"
+ if req.EnableSocks && !enableMulti && !req.EnableSS && !req.EnableVMess && !req.EnableVLESS && !req.EnableReality && !req.EnableHY2 {
+ nodeKind = "socks5"
+ } else if enableMulti {
+ nodeKind = "multi"
+ }
+
+ nodeID := "node-" + strings.ReplaceAll(host, ".", "-")
+ if suffix, err := controlRandomHex(2); err == nil {
+ nodeID += "-" + suffix
+ }
+ uuid, err := controlRandomUUID()
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ vmessUUID, err := controlRandomUUID()
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ ssPassword, err := controlRandomHex(16)
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ hy2Password, err := controlRandomBase64(16)
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ hy2ObfsPassword, err := controlRandomHex(32)
+ if err != nil {
+ return control.Node{}, "", err
+ }
+
+ protocols := make([]control.ProtocolProfile, 0, 3)
+ if req.EnableVLESS {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &control.TLSProfile{
+ Enabled: true,
+ },
+ Auth: &control.AuthProfile{
+ UUID: uuid,
+ },
+ Extra: map[string]any{
+ "transport_type": "ws",
+ "path": "/ws",
+ },
+ })
+ }
+ if req.EnableReality && !enableMulti {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ UUID: uuid,
+ },
+ Reality: &control.VLESSRealityProfile{
+ ServerName: "www.nokia.com",
+ ServerPort: 443,
+ Fingerprint: "chrome",
+ },
+ })
+ }
+ if req.EnableSocks {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "socks5",
+ Enabled: true,
+ Port: 54101,
+ })
+ }
+ if req.EnableSS {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "shadowsocks",
+ Enabled: true,
+ Port: 8443,
+ Auth: &control.AuthProfile{
+ Method: "2022-blake3-aes-128-gcm",
+ Password: ssPassword,
+ },
+ })
+ }
+ if req.EnableVMess {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vmess",
+ Enabled: true,
+ Port: 8444,
+ TLS: &control.TLSProfile{
+ Enabled: true,
+ },
+ Auth: &control.AuthProfile{
+ UUID: vmessUUID,
+ },
+ Extra: map[string]any{
+ "path": "/vmess",
+ },
+ })
+ }
+ if req.EnableHY2 && !enableMulti {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ Password: hy2Password,
+ },
+ Hysteria2: &control.Hysteria2Profile{
+ Port: 443,
+ UpMbps: 100,
+ DownMbps: 100,
+ ObfsPassword: hy2ObfsPassword,
+ UserPassword: hy2Password,
+ CertPath: "/etc/sing-box/cert.pem",
+ KeyPath: "/etc/sing-box/key.pem",
+ },
+ })
+ }
+ if enableMulti {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ UUID: uuid,
+ },
+ Reality: &control.VLESSRealityProfile{
+ ServerName: "www.nokia.com",
+ ServerPort: 443,
+ Fingerprint: "chrome",
+ },
+ }, control.ProtocolProfile{
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ Password: hy2Password,
+ },
+ Hysteria2: &control.Hysteria2Profile{
+ Port: 443,
+ UpMbps: 100,
+ DownMbps: 100,
+ ObfsPassword: hy2ObfsPassword,
+ UserPassword: hy2Password,
+ CertPath: "/etc/sing-box/cert.pem",
+ KeyPath: "/etc/sing-box/key.pem",
+ },
+ })
+ }
+ if len(protocols) == 0 {
+ return control.Node{}, "", errors.New("at least one protocol must be enabled")
+ }
+
+ node := control.Node{
+ ID: nodeID,
+ Name: generateQuickNodeName(region, nodeKind, host),
+ Provider: provider,
+ Region: region,
+ Host: host,
+ ACMEEmail: acmeEmail,
+ Enabled: true,
+ SSH: control.SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "password",
+ PasswordEnv: "VPNEM_RUNTIME_PASSWORD",
+ Password: password,
+ },
+ Protocols: protocols,
+ Metadata: map[string]string{
+ "provision_mode": "quick",
+ },
+ }
+ return node, password, nil
+}
+
+func (h *Handler) provisionNodeFlow(ctx context.Context, node *control.Node, sshPassword string) (map[string]any, error) {
+ response := map[string]any{
+ "node_id": node.ID,
+ }
+ if sshPassword != "" {
+ node.SSH.Password = sshPassword
+ }
+
+ if strings.TrimSpace(node.Domain) == "" && nodeNeedsProvisionedDNS(*node) {
+ client := control.PorkbunClient{
+ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")),
+ SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")),
+ }
+ fqdn, err := client.EnsureRandomARecord(ctx, "em-sysadmin.xyz", dnsPrefixForNode(*node), strings.TrimSpace(node.Host), 600)
+ if err != nil {
+ return nil, err
+ }
+
+ node.Domain = fqdn
+ for idx := range node.Protocols {
+ if node.Protocols[idx].TLS != nil && node.Protocols[idx].TLS.Enabled {
+ node.Protocols[idx].TLS.ServerName = fqdn
+ }
+ }
+ if node.Metadata == nil {
+ node.Metadata = map[string]string{}
+ }
+ node.Metadata["dns_zone"] = "em-sysadmin.xyz"
+ node.Metadata["dns_provider"] = "porkbun"
+ if _, err := control.SaveNodeFile(h.inventoryDir(), *node); err != nil {
+ return nil, err
+ }
+
+ now := time.Now().UTC()
+ state, _ := control.LoadNodeState(h.stateDir(), node.ID)
+ if state == nil {
+ state = &control.NodeState{
+ NodeID: node.ID,
+ PublicHost: fqdn,
+ Services: []control.ServiceStatus{},
+ Metadata: map[string]any{},
+ }
+ }
+ state.PublicHost = fqdn
+ state.LastDNSSyncAt = &now
+ if state.Metadata == nil {
+ state.Metadata = map[string]any{}
+ }
+ state.Metadata["dns_provider"] = "porkbun"
+ state.Metadata["dns_zone"] = "em-sysadmin.xyz"
+ state.Metadata["dns_fqdn"] = fqdn
+ if err := control.SaveNodeState(h.stateDir(), *state); err != nil {
+ return nil, err
+ }
+ response["dns"] = map[string]any{
+ "provisioned": true,
+ "fqdn": fqdn,
+ }
+ } else {
+ response["dns"] = map[string]any{
+ "provisioned": false,
+ "fqdn": node.Domain,
+ "skipped": dnsSkipReason(*node),
+ }
+ }
+
+ savedPath, err := control.SaveNodeFile(h.inventoryDir(), *node)
+ if err != nil {
+ return nil, err
+ }
+ savedNode, err := control.LoadNodeFile(savedPath)
+ if err != nil {
+ return nil, err
+ }
+ *node = *savedNode
+ if sshPassword != "" {
+ node.SSH.Password = sshPassword
+ }
+
+ bootstrapState, err := control.BootstrapNode(ctx, control.SSHRunner{}, *node, control.BootstrapOptions{
+ StateDir: h.stateDir(),
+ DryRun: false,
+ })
+ if err != nil {
+ return nil, err
+ }
+ response["bootstrap"] = bootstrapState
+
+ checkState, err := control.CheckNode(ctx, control.SSHRunner{}, *node, h.stateDir())
+ if err != nil {
+ return nil, err
+ }
+ response["check"] = checkState
+
+ published := false
+ if canPublishNodeState(*checkState) {
+ inventory, err := control.LoadInventoryDir(h.inventoryDir())
+ if err != nil {
+ return nil, err
+ }
+ states := make(map[string]*control.NodeState, len(inventory.Nodes))
+ for _, item := range inventory.Nodes {
+ state, err := control.LoadNodeState(h.stateDir(), item.ID)
+ if err != nil {
+ if control.IsNotExist(err) {
+ continue
+ }
+ return nil, err
+ }
+ states[item.ID] = state
+ }
+ publishable := control.PublishableNodes(inventory.Nodes, states)
+ target := filepath.Join(h.store.DataDir(), "servers.json")
+ if err := control.WriteLegacyCatalog(target, publishable); err != nil {
+ return nil, err
+ }
+ catalogTarget := filepath.Join(h.store.DataDir(), "catalog-v2.json")
+ if err := control.WriteCatalogV2(catalogTarget, publishable, states); err != nil {
+ return nil, err
+ }
+ published = true
+ response["publish"] = map[string]any{
+ "published": true,
+ "target": target,
+ "catalog_v2_target": catalogTarget,
+ "count": len(publishable),
+ }
+ } else {
+ response["publish"] = map[string]any{
+ "published": false,
+ "reason": "узел ещё не готов и не healthy",
+ }
+ }
+ response["ready_for_catalog"] = published
+ return response, nil
+}
+
+func controlRandomHex(size int) (string, error) { return control.RandomHexForAPI(size) }
+func controlRandomBase64(size int) (string, error) { return control.RandomBase64ForAPI(size) }
+func controlRandomUUID() (string, error) { return control.RandomUUIDForAPI() }
+
+func nodeNeedsProvisionedDNS(node control.Node) bool {
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ switch protocol.Type {
+ case "vless":
+ if protocol.TLS != nil && protocol.TLS.Enabled {
+ return true
+ }
+ case "vmess":
+ if protocol.TLS != nil && protocol.TLS.Enabled {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func dnsSkipReason(node control.Node) string {
+ if strings.TrimSpace(node.Domain) != "" {
+ return "домен уже задан"
+ }
+ return "selected protocols do not require a public domain"
+}
+
+const vpnUIHTML = `<!doctype html>
+<html lang="ru">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Панель управления vpnem</title>
+ <style>
+ :root {
+ --bg: #f3efe7;
+ --panel: rgba(255, 251, 245, 0.82);
+ --ink: #1f2430;
+ --muted: #66645f;
+ --line: rgba(132, 110, 82, 0.18);
+ --accent: #0f766e;
+ --accent-2: #c2410c;
+ --accent-soft: rgba(15, 118, 110, 0.1);
+ }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0;
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
+ color: var(--ink);
+ background:
+ radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 24%),
+ radial-gradient(circle at 85% 10%, rgba(194, 65, 12, 0.14), transparent 20%),
+ radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.08), transparent 28%),
+ var(--bg);
+ min-height: 100vh;
+ }
+ .shell {
+ max-width: 1360px;
+ margin: 0 auto;
+ padding: 18px 18px 32px;
+ }
+ @keyframes riseIn {
+ from { opacity: 0; transform: translateY(14px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @keyframes glowShift {
+ 0% { transform: translate3d(0, 0, 0) scale(1); }
+ 50% { transform: translate3d(10px, -8px, 0) scale(1.04); }
+ 100% { transform: translate3d(0, 0, 0) scale(1); }
+ }
+ .hero {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ margin-bottom: 18px;
+ padding: 20px;
+ border: 1px solid var(--line);
+ border-radius: 24px;
+ background:
+ linear-gradient(145deg, rgba(255,255,255,0.92), rgba(255,248,240,0.84)),
+ var(--panel);
+ box-shadow: 0 24px 70px rgba(31, 36, 48, 0.08);
+ position: relative;
+ overflow: hidden;
+ animation: riseIn .5s ease both;
+ }
+ .hero::after {
+ content: "";
+ position: absolute;
+ width: 280px;
+ height: 280px;
+ right: -80px;
+ top: -100px;
+ border-radius: 999px;
+ background: radial-gradient(circle, rgba(15,118,110,0.16), rgba(15,118,110,0));
+ pointer-events: none;
+ animation: glowShift 8s ease-in-out infinite;
+ }
+ h1 {
+ margin: 0;
+ font-size: 36px;
+ line-height: 0.98;
+ letter-spacing: -0.04em;
+ max-width: 780px;
+ }
+ .hero-copy {
+ display: grid;
+ gap: 10px;
+ max-width: 780px;
+ position: relative;
+ z-index: 1;
+ }
+ .hero-kicker {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-radius: 999px;
+ background: rgba(15, 118, 110, 0.1);
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ width: fit-content;
+ }
+ .sub {
+ color: var(--muted);
+ max-width: 760px;
+ line-height: 1.48;
+ font-size: 14px;
+ }
+ .hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 4px;
+ }
+ .hero-rail {
+ display: grid;
+ gap: 10px;
+ min-width: 270px;
+ position: relative;
+ z-index: 1;
+ }
+ .hero-note {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 11px 13px;
+ background: rgba(255,255,255,0.76);
+ font-size: 12px;
+ color: var(--muted);
+ line-height: 1.38;
+ backdrop-filter: blur(10px);
+ }
+ .hero-note strong {
+ color: var(--ink);
+ font-size: 13px;
+ }
+ .workspace-grid {
+ display: grid;
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+ }
+ .panel {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 14px;
+ box-shadow: 0 14px 44px rgba(31, 36, 48, 0.07);
+ backdrop-filter: blur(10px);
+ animation: riseIn .5s ease both;
+ }
+ .panel-shell {
+ display: grid;
+ gap: 16px;
+ }
+ .section-shell {
+ display: grid;
+ gap: 12px;
+ }
+ .surface-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+ margin-bottom: 4px;
+ }
+ .surface-title {
+ margin: 0;
+ font-size: 22px;
+ letter-spacing: -0.03em;
+ }
+ .surface-sub {
+ margin: 4px 0 0;
+ color: var(--muted);
+ max-width: 720px;
+ line-height: 1.42;
+ font-size: 14px;
+ }
+ .panel h2 {
+ margin: 0 0 10px;
+ font-size: 17px;
+ }
+ .stack { display: grid; gap: 10px; }
+ .row { display: grid; gap: 8px; }
+ .cols-2 { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }
+ .cols-3 { display: grid; gap: 10px; grid-template-columns: repeat(3, 1fr); }
+ label {
+ font-size: 13px;
+ color: var(--muted);
+ display: grid;
+ gap: 6px;
+ }
+ input, select, textarea, button {
+ font: inherit;
+ }
+ input, select, textarea {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ padding: 9px 11px;
+ color: var(--ink);
+ }
+ textarea { min-height: 88px; resize: vertical; }
+ button {
+ border: 0;
+ border-radius: 999px;
+ padding: 9px 14px;
+ cursor: pointer;
+ background: linear-gradient(135deg, #0f766e, #115e59);
+ color: #fff;
+ font-weight: 600;
+ box-shadow: 0 10px 24px rgba(15, 118, 110, 0.18);
+ transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease, background .16s ease;
+ }
+ button:hover { transform: translateY(-1px); box-shadow: 0 14px 28px rgba(15, 118, 110, 0.22); }
+ button.alt { background: linear-gradient(135deg, #c2410c, #9a3412); }
+ button.warn { background: linear-gradient(135deg, #9a3412, #7c2d12); }
+ button.ghost {
+ background: rgba(255,255,255,0.58);
+ color: var(--ink);
+ border: 1px solid var(--line);
+ box-shadow: none;
+ }
+ button.ghost:hover {
+ background: rgba(255,255,255,0.92);
+ box-shadow: none;
+ }
+ .node-list {
+ display: grid;
+ gap: 6px;
+ max-height: 420px;
+ overflow: auto;
+ }
+ .node-card {
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.8);
+ border-radius: 12px;
+ padding: 8px;
+ cursor: pointer;
+ transition: transform .12s ease, border-color .12s ease;
+ }
+ .node-card:hover { transform: translateY(-1px); border-color: var(--accent); }
+ .node-meta { color: var(--muted); font-size: 12px; margin-top: 3px; }
+ .protocol-box {
+ border: 1px dashed var(--line);
+ border-radius: 14px;
+ padding: 12px;
+ background: rgba(255,255,255,0.55);
+ }
+ .collapsible-card[open] summary {
+ margin-bottom: 12px;
+ }
+ .collapsible-card summary {
+ list-style: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-weight: 700;
+ color: var(--ink);
+ }
+ .collapsible-card summary::-webkit-details-marker { display: none; }
+ .summary-hint {
+ font-size: 12px;
+ color: var(--muted);
+ font-weight: 500;
+ }
+ .section-card {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255,255,255,0.78);
+ padding: 14px;
+ }
+ .section-head {
+ margin-bottom: 10px;
+ }
+ .section-head h3 {
+ margin: 0 0 4px;
+ font-size: 17px;
+ color: var(--ink);
+ }
+ .section-head .tip {
+ margin: 0;
+ }
+ .toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ .toolbar button {
+ padding: 7px 12px;
+ font-size: 12px;
+ }
+ .toolbar button.ghost {
+ padding: 6px 11px;
+ }
+ .status {
+ min-height: 22px;
+ font-size: 14px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 10px 12px;
+ background: rgba(255,255,255,0.78);
+ color: var(--ink);
+ }
+ .status.info {
+ border-color: #bfdbfe;
+ background: #eff6ff;
+ }
+ .status.success {
+ border-color: #a7f3d0;
+ background: #ecfdf5;
+ }
+ .status.error {
+ border-color: #fdba74;
+ background: #fff7ed;
+ }
+ .tip {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.35;
+ }
+ .warn {
+ border-left: 3px solid var(--accent-2);
+ padding-left: 10px;
+ }
+ .checks {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .preset-grid {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .preset-card {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 11px 12px;
+ background: rgba(255,255,255,0.72);
+ cursor: pointer;
+ transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
+ }
+ .preset-card:hover {
+ transform: translateY(-1px);
+ border-color: var(--accent);
+ }
+ .preset-card.active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14);
+ background: rgba(240, 253, 250, 0.8);
+ }
+ .preset-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--ink);
+ margin-bottom: 4px;
+ }
+ .preset-meta {
+ font-size: 12px;
+ color: var(--muted);
+ line-height: 1.32;
+ }
+ .check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 12px;
+ background: #fff;
+ color: var(--ink);
+ }
+ .check input {
+ width: auto;
+ margin: 0;
+ }
+ .system-note {
+ display: grid;
+ gap: 8px;
+ }
+ .system-note {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(255,255,255,0.8);
+ padding: 12px;
+ }
+ .system-note .eyebrow {
+ font-size: 11px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: 6px;
+ }
+ .system-note .value {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--ink);
+ line-height: 1.35;
+ }
+ .system-note .meta {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--muted);
+ }
+ .node-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 10px;
+ }
+ .node-title {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--ink);
+ }
+ .badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 4px;
+ }
+ .filter-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-top: 2px;
+ align-items: flex-start;
+ align-content: flex-start;
+ }
+ .filter-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ align-self: flex-start;
+ border-radius: 11px;
+ padding: 4px 9px;
+ background: rgba(255,255,255,0.78);
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ color: #334155;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.05;
+ box-shadow: none;
+ min-height: 0;
+ white-space: nowrap;
+ }
+ .filter-chip:hover {
+ transform: none;
+ background: rgba(255,255,255,0.98);
+ border-color: rgba(15, 118, 110, 0.24);
+ box-shadow: none;
+ }
+ .filter-chip.alt {
+ background: linear-gradient(135deg, #c2410c, #9a3412);
+ border-color: transparent;
+ color: #fff;
+ box-shadow: 0 8px 18px rgba(154, 52, 18, 0.15);
+ }
+ .filter-chip.alt:hover {
+ transform: none;
+ box-shadow: 0 8px 18px rgba(154, 52, 18, 0.15);
+ }
+ .badge {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ padding: 3px 7px;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ }
+ .badge.ready {
+ background: #dcfce7;
+ color: #166534;
+ }
+ .badge.blocked {
+ background: #ffedd5;
+ color: #9a3412;
+ }
+ .badge.active {
+ background: #dbeafe;
+ color: #1d4ed8;
+ }
+ .badge.idle {
+ background: #e5e7eb;
+ color: #374151;
+ }
+ .badge.protocol {
+ background: #f3f4f6;
+ color: #334155;
+ font-weight: 600;
+ }
+ .list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-top: 6px;
+ }
+ .muted-box {
+ border: 1px dashed var(--line);
+ border-radius: 14px;
+ padding: 12px;
+ color: var(--muted);
+ background: rgba(255,255,255,0.45);
+ }
+ .empty-box {
+ padding: 10px 12px;
+ font-size: 12px;
+ line-height: 1.35;
+ }
+ .debug-box {
+ margin-top: 12px;
+ }
+ .debug-box summary {
+ cursor: pointer;
+ font-weight: 600;
+ }
+ .ready-grid {
+ display: grid;
+ gap: 10px;
+ }
+ .ready-card {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(255,255,255,0.88);
+ padding: 12px;
+ display: grid;
+ gap: 8px;
+ }
+ .ready-card-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ }
+ .ready-card-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--ink);
+ }
+ .ready-card-sub {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 2px;
+ }
+ .mono {
+ font-family: "IBM Plex Mono", "Cascadia Code", monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #243244;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 12px;
+ overflow: auto;
+ word-break: break-all;
+ }
+ .settings-grid {
+ display: grid;
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+ }
+ @media (max-width: 920px) {
+ .hero { flex-direction: column; }
+ .workspace-grid, .settings-grid { grid-template-columns: 1fr; }
+ .cols-2, .cols-3 { grid-template-columns: 1fr; }
+ .preset-grid,
+ .checks { grid-template-columns: 1fr; }
+ }
+ @media (max-width: 640px) {
+ }
+ </style>
+</head>
+<body>
+ <div class="shell">
+ <div class="hero">
+ <div class="hero-copy">
+ <div class="hero-kicker">Панель VPN</div>
+ <h1>Один экран для установки, ремонта и управления VPN-узлами.</h1>
+ <div class="sub">Сверху находится простой путь: вставьте IP сервера, введите root-пароль и получите готовый узел. Ниже остаётся тонкая настройка на тот случай, если нужно чинить, обновлять, добавлять SOCKS5 или вручную переопределять параметры.</div>
+ <div class="hero-actions">
+ <button id="jumpInstallBtn" type="button">Начать установку</button>
+ <button id="jumpAdvancedBtn" class="ghost" type="button">Открыть тонкую настройку</button>
+ </div>
+ </div>
+ <div class="hero-rail">
+ <div class="hero-note"><strong>Шаг 1</strong><br>Проверьте VPS и убедитесь, что панель говорит, можно ли ставить <strong>MULTI</strong> или <strong>SOCKS5</strong>.</div>
+ <div class="hero-note"><strong>Шаг 2</strong><br>Создайте узел, дождитесь проверки и сразу скопируйте готовую ссылку из карточек справа.</div>
+ <div class="hero-note"><strong>Тонкая настройка</strong><br>Если VPS уже под управлением, спускайтесь ниже: там есть обновление, ремонт, чистая переустановка и ручные override-поля.</div>
+ </div>
+ </div>
+
+ <div class="panel-shell">
+ <section id="quickStart" class="section-shell">
+ <div class="surface-head">
+ <div>
+ <h2 class="surface-title">Быстрая установка</h2>
+ <p class="surface-sub">Это главный путь. Если нужен просто рабочий сервер, оставайтесь здесь: сначала проверка VPS, потом установка, потом копирование готовых ссылок.</p>
+ </div>
+ </div>
+ <div class="workspace-grid">
+ <div class="stack">
+ <div class="panel stack">
+ <h2>Быстрая установка</h2>
+ <div class="tip">Это самый простой сценарий. Вставьте IP сервера, введите root-пароль, выберите тип узла и дождитесь, пока панель сама всё сделает: установит, проверит и покажет готовые ссылки.</div>
+ <div class="muted-box">
+ <strong>Как это работает</strong><br>
+ 1. Нажмите «Проверить VPS».<br>
+ 2. Оставьте включённым <strong>MULTI</strong>, если нужен основной современный режим.<br>
+ 3. Нажмите «Создать прокси».<br>
+ 4. Скопируйте готовую ссылку из блока ниже.
+ </div>
+ <div class="cols-2">
+ <label>Сервер (IP или домен)<input id="quickHost" placeholder="89.124.96.166"></label>
+ <label>Root-пароль<input id="quickRootPassword" type="password" placeholder="root-пароль"></label>
+ </div>
+ <details class="protocol-box collapsible-card">
+ <summary>Дополнительно <span class="summary-hint">Все поля уже заполнены по умолчанию</span></summary>
+ <div class="cols-3" style="margin-top:12px">
+ <label>Регион<input id="quickRegion" value="auto"></label>
+ <label>Провайдер<input id="quickProvider" value="custom-vps"></label>
+ <label>Email для ACME<input id="quickACMEEmail" value="admin@em-sysadmin.xyz"></label>
+ </div>
+ </details>
+ <div class="stack">
+ <strong>Выберите готовый сценарий</strong>
+ <div id="quickPresetGrid" class="preset-grid">
+ <button class="preset-card active" type="button" data-preset="multi">
+ <div class="preset-title">Обычный сервер</div>
+ <div class="preset-meta">Рекомендуется. TCP через REALITY, UDP через Hysteria2.</div>
+ </button>
+ <button class="preset-card" type="button" data-preset="multi+socks">
+ <div class="preset-title">Сервер + SOCKS5</div>
+ <div class="preset-meta">Основной MULTI-режим плюс fallback SOCKS5 на порту 54101.</div>
+ </button>
+ <button class="preset-card" type="button" data-preset="socks">
+ <div class="preset-title">Только SOCKS5</div>
+ <div class="preset-meta">Простой вариант без MULTI, только SOCKS5 на порту 54101.</div>
+ </button>
+ </div>
+ <div id="quickPresetSummary" class="tip">Сейчас выбран сценарий <strong>Обычный сервер</strong>.</div>
+ </div>
+ <div class="checks" style="display:none">
+ <label class="check"><input id="quickEnableMulti" type="checkbox" checked>MULTI (REALITY + Hysteria2)</label>
+ <label class="check"><input id="quickEnableSocks" type="checkbox">SOCKS5</label>
+ </div>
+ <div class="tip">Если не уверены, оставляйте пресет <strong>Обычный сервер</strong>. Для SOCKS5 по умолчанию используется порт <code>54101</code>.</div>
+ <div id="quickDefaults" class="muted-box">
+ <strong>Что заполнится автоматически</strong><br>
+ Имя сервера будет создано само.<br>
+ Для <strong>MULTI</strong> по умолчанию используются уже проверенные настройки: TCP через REALITY, UDP через Hysteria2, порт <code>443</code>, рабочий SNI и безопасные transport-параметры.<br>
+ Для <strong>SOCKS5</strong> по умолчанию используется порт <code>54101</code>.
+ </div>
+ <div id="quickHostStatus" class="muted-box" style="display:none"></div>
+ <div id="quickStatusRail" class="badges" style="display:none"></div>
+ <div id="quickGuide" class="muted-box" style="display:none"></div>
+ <div class="toolbar">
+ <button id="quickInspectBtn" class="ghost" type="button">Проверить VPS</button>
+ <button id="quickProvisionBtn" type="button">Создать прокси</button>
+ </div>
+ </div>
+ <div id="status" class="status info">Готово.</div>
+ <div class="tip warn">Если панель доступна публично, не отключайте админ-токен. Главная вкладка рассчитана на простой сценарий с IP и паролем.</div>
+ <div id="currentSystem" class="system-note">
+ <div class="eyebrow">Сейчас в системе</div>
+ <div id="currentSystemValue" class="value">Пока нет ни одного сервера.</div>
+ <div id="currentSystemMeta" class="meta">Начните с проверки VPS, затем создайте первый сервер и скопируйте готовую ссылку.</div>
+ </div>
+ </div>
+
+ <div class="stack">
+ <div class="panel stack">
+ <div class="toolbar" style="justify-content:space-between;align-items:center">
+ <h2 style="margin:0">Мои серверы</h2>
+ <div class="toolbar">
+ <button id="refreshBtn" class="ghost" type="button">Обновить</button>
+ <button id="publishBtn" class="ghost" type="button">Перепубликовать каталог</button>
+ </div>
+ </div>
+ <div id="fleetFilters" class="filter-chips">
+ <button class="ghost filter-chip" type="button" data-fleet-filter="all">Все</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="ready">Готовы</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="repair">Нужен ремонт</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="managed">Уже под управлением</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="multi">MULTI</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="socks5">SOCKS5</button>
+ </div>
+ <div id="nodeList" class="node-list"></div>
+ </div>
+
+ <div class="panel stack">
+ <div class="toolbar" style="justify-content:space-between;align-items:center">
+ <h2 style="margin:0">Подключение</h2>
+ <button id="copySummaryBtn" class="ghost" type="button">Копировать сводку</button>
+ </div>
+ <div class="tip">Используйте этот блок после того, как узел станет healthy и готовым к публикации. Здесь находятся ссылки и параметры подключения для прямого копирования.</div>
+ <div id="readyCards" class="ready-grid"></div>
+ <details class="debug-box">
+ <summary>Сырая сводка</summary>
+ <pre id="summaryView" style="margin:12px 0 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#fff;overflow:auto;min-height:180px"></pre>
+ </details>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section id="advancedControls" class="section-shell">
+ <div class="surface-head">
+ <div>
+ <h2 class="surface-title">Тонкая настройка и сервисные действия</h2>
+ <p class="surface-sub">Этот блок нужен, когда узел уже существует и его надо обновлять, ремонтировать, дооснащать SOCKS5 или вручную править конфиг.</p>
+ </div>
+ </div>
+ <div class="settings-grid">
+ <div class="stack">
+ <div class="panel">
+ <h2>Что здесь можно делать</h2>
+ <div class="tip">Выбирайте узел в списке выше и используйте основные действия. Ручные поля протоколов нужны редко и остаются ниже как сервисный слой.</div>
+ </div>
+ <details id="accessPanel" class="panel">
+ <summary style="cursor:pointer;font-weight:600">Доступ</summary>
+ <div class="row" style="margin-top:12px">
+ <label>Ключ доступа
+ <input id="adminToken" placeholder="Необязательно. Используется, когда задан VPNEM_ADMIN_TOKEN">
+ </label>
+ <div class="tip">Панель можно открывать по magic-link вроде ` + "`/vpnui/?token=...`" + ` или ` + "`/vpnui/#token=...`" + `. Ключ будет сохранён в этом браузере и удалён из URL.</div>
+ </div>
+ </details>
+ </div>
+
+ <div class="panel">
+ <form id="nodeForm" class="stack">
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Данные узла</h3>
+ <div class="tip">Базовые данные о VPS и публичном хосте, который будут использовать клиенты.</div>
+ </div>
+ <div class="cols-2">
+ <label>ID узла<input name="id" required></label>
+ <label>Название<input name="name" required></label>
+ </div>
+ <div class="cols-3">
+ <label>Провайдер<input name="provider" value="custom-vps"></label>
+ <label>Регион<input name="region" required placeholder="nl"></label>
+ <label>Включён
+ <select name="enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ </div>
+ <div class="cols-2">
+ <label>Хост<input name="host" required placeholder="203.0.113.10"></label>
+ <label>Домен<input name="domain" placeholder="nl-01.example.com"></label>
+ </div>
+ <div class="cols-2">
+ <label>Email для ACME<input name="acme_email" placeholder="admin@example.com"></label>
+ <div class="tip">Используйте настройки только тогда, когда простого сценария “Создать прокси” уже недостаточно и нужен точный контроль над узлом.</div>
+ </div>
+ </div>
+
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Доступ к серверу</h3>
+ <div class="tip">Как ` + "`vpnui`" + ` должен входить на VPS для bootstrap, проверок, обновления и удаления узла.</div>
+ </div>
+ <div class="cols-3">
+ <label>SSH-пользователь<input name="ssh_user" value="root" required></label>
+ <label>SSH-порт<input name="ssh_port" type="number" value="22" required></label>
+ <label>Тип SSH-входа
+ <select name="ssh_auth">
+ <option value="key">key</option>
+ <option value="password">password</option>
+ </select>
+ </label>
+ </div>
+ <div class="cols-2">
+ <label>Файл SSH-ключа<input name="ssh_identity" placeholder="~/.ssh/id_ed25519"></label>
+ <label>Переменная окружения с SSH-паролем<input name="ssh_password_env" placeholder="VPNEM_NODE_PASSWORD"></label>
+ </div>
+ <div class="cols-2">
+ <label>Временный SSH-пароль<input name="ssh_runtime_password" type="password" placeholder="Нужен только для bootstrap/check"></label>
+ <div class="tip">Если сервер использует вход по паролю, введите текущий root-пароль перед bootstrap или проверками. Он отправляется только вместе с действием и не сохраняется в inventory.</div>
+ </div>
+ </div>
+
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Основные действия</h3>
+ <div class="tip">Используйте эти кнопки для обычного обслуживания уже существующего узла. Сырые поля протоколов ниже обычно трогать не нужно.</div>
+ </div>
+ <div id="nodeStatusRail" class="badges" style="display:none"></div>
+ <div id="nodeGuide" class="muted-box">Выберите узел, чтобы увидеть самый безопасный следующий шаг.</div>
+ <div class="toolbar">
+ <button id="upgradeBtn" class="alt" type="button">Обновить сервер</button>
+ <button id="addSocks5Btn" class="alt" type="button">Добавить SOCKS5</button>
+ <button id="repairReinstallBtn" class="alt" type="button">Починить сервер</button>
+ <button id="cleanReinstallBtn" class="warn" type="button">Переустановить сервер</button>
+ <button id="checkBtn" class="alt" type="button">Проверить сервер</button>
+ </div>
+ <div class="tip">Используйте <strong>Добавить SOCKS5</strong>, когда MULTI-узел уже работает и вы хотите добавить fallback-прокси на порту 54101 на том же VPS.</div>
+ </div>
+
+ <details class="section-card stack collapsible-card">
+ <summary>Ручные переопределения протоколов <span class="summary-hint">Нужно только если вы точно знаете, что меняете</span></summary>
+ <div class="section-head" style="margin-top:12px">
+ <h3>Протоколы</h3>
+ <div class="tip">Этот раздел нужен только для ручных переопределений. В обычном сценарии достаточно быстрой установки на главной вкладке и основных действий выше.</div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>VLESS</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="vless_enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ <label>Порт<input name="vless_port" type="number" value="443"></label>
+ <label>UUID<input name="vless_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label>
+ </div>
+ <div class="cols-3">
+ <label>TLS включён
+ <select name="vless_tls_enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ <label>Имя сервера / SNI<input name="vless_server_name" placeholder="nl-01.example.com"></label>
+ <label>Тип транспорта
+ <select name="vless_transport_type">
+ <option value="">none</option>
+ <option value="ws">ws</option>
+ <option value="httpupgrade">httpupgrade</option>
+ <option value="grpc">grpc</option>
+ </select>
+ </label>
+ </div>
+ <label>Путь транспорта<input name="vless_path" placeholder="/ws"></label>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>VLESS REALITY</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="reality_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="reality_port" type="number" value="443"></label>
+ <label>UUID<input name="reality_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label>
+ </div>
+ <div class="cols-3">
+ <label>Имя сервера / SNI<input name="reality_server_name" placeholder="www.nokia.com"></label>
+ <label>Порт handshake<input name="reality_server_port" type="number" value="443"></label>
+ <label>Отпечаток браузера<input name="reality_fingerprint" placeholder="chrome"></label>
+ </div>
+ <div class="cols-3">
+ <label>Публичный ключ<input name="reality_public_key" placeholder="сгенерируется автоматически, если пусто"></label>
+ <label>Приватный ключ<input name="reality_private_key" placeholder="сгенерируется автоматически, если пусто"></label>
+ <label>Короткий ID<input name="reality_short_id" placeholder="сгенерируется автоматически, если пусто"></label>
+ </div>
+ <div class="tip">REALITY использует server-side reality TLS внутри sing-box и не требует публичный домен или ACME-сертификаты.</div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>Shadowsocks</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="ss_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="ss_port" type="number" value="8443"></label>
+ <label>Метод<input name="ss_method" placeholder="2022-blake3-aes-128-gcm"></label>
+ </div>
+ <label>Пароль<input name="ss_password" placeholder="secret"></label>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>SOCKS5</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="socks_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="socks_port" type="number" value="1080"></label>
+ <div class="tip">Простой прямой SOCKS5 listener без TLS-слоя.</div>
+ </div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>VMess</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="vmess_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="vmess_port" type="number" value="443"></label>
+ <label>UUID<input name="vmess_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label>
+ </div>
+ <div class="cols-3">
+ <label>TLS включён
+ <select name="vmess_tls_enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ <label>Имя сервера / SNI<input name="vmess_server_name" placeholder="vmess.example.com"></label>
+ <label>Путь<input name="vmess_path" placeholder="/vmess"></label>
+ </div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>Hysteria2</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="hy2_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="hy2_port" type="number" value="8443"></label>
+ <label>Пароль<input name="hy2_password" placeholder="hy2-secret"></label>
+ </div>
+ <div class="cols-3">
+ <label>Скорость вверх, Mbps<input name="hy2_up_mbps" type="number" placeholder="необязательно"></label>
+ <label>Скорость вниз, Mbps<input name="hy2_down_mbps" type="number" placeholder="необязательно"></label>
+ <label>Пароль obfs<input name="hy2_obfs_password" placeholder="необязательно"></label>
+ </div>
+ <div class="cols-2">
+ <label>Путь к TLS-сертификату<input name="hy2_tls_cert_path" placeholder="/opt/vpnem-node/certs/fullchain.pem"></label>
+ <label>Путь к TLS-ключу<input name="hy2_tls_key_path" placeholder="/opt/vpnem-node/certs/privkey.pem"></label>
+ </div>
+ </div>
+ </details>
+
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Сохранение и жизненный цикл</h3>
+ <div class="tip">Сохраняйте ручные изменения только тогда, когда вы специально меняли конфигурацию узла. Ниже остаются расширенные действия для DNS и разрушительных операций.</div>
+ </div>
+ <div class="toolbar">
+ <button type="submit">Сохранить узел</button>
+ <button id="resetBtn" class="ghost" type="button">Сбросить</button>
+ </div>
+ <details class="protocol-box">
+ <summary style="cursor:pointer;font-weight:600">Расширенные действия</summary>
+ <div class="toolbar" style="margin-top:12px">
+ <button id="enableNodeBtn" class="ghost" type="button">Включить узел</button>
+ <button id="disableNodeBtn" class="ghost" type="button">Выключить узел</button>
+ <button id="rotateSecretsBtn" class="ghost" type="button">Сменить секреты</button>
+ <button id="provisionDnsBtn" class="ghost" type="button">Создать DNS</button>
+ <button id="provisionNodeBtn" class="alt" type="button">Подготовить узел</button>
+ <button id="deleteDnsBtn" class="ghost" type="button">Удалить DNS</button>
+ <button id="bootstrapDryRunBtn" class="ghost" type="button">Bootstrap без запуска</button>
+ <button id="bootstrapBtn" type="button">Запустить bootstrap</button>
+ <button id="destroyNodeBtn" class="warn" type="button">Удалить сервер</button>
+ </div>
+ </details>
+ </div>
+ </form>
+ <details class="debug-box">
+ <summary>Техническая диагностика</summary>
+ <pre id="stateView" style="margin:12px 0 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#fff;overflow:auto;min-height:180px"></pre>
+ </details>
+ </div>
+ </div>
+ </section>
+ </div>
+ </div>
+
+ <script>
+ const state = { nodes: [], states: {}, publishDecisions: {}, selectedNodeID: '', fleetFilter: 'all' };
+ const tokenInput = document.getElementById('adminToken');
+ const statusEl = document.getElementById('status');
+ const nodeListEl = document.getElementById('nodeList');
+ const fleetFiltersEl = document.getElementById('fleetFilters');
+ const stateViewEl = document.getElementById('stateView');
+ const summaryViewEl = document.getElementById('summaryView');
+ const readyCardsEl = document.getElementById('readyCards');
+ const currentSystemValueEl = document.getElementById('currentSystemValue');
+ const currentSystemMetaEl = document.getElementById('currentSystemMeta');
+ const form = document.getElementById('nodeForm');
+ const quickHostEl = document.getElementById('quickHost');
+ const quickRootPasswordEl = document.getElementById('quickRootPassword');
+ const quickRegionEl = document.getElementById('quickRegion');
+ const quickProviderEl = document.getElementById('quickProvider');
+ const quickACMEEmailEl = document.getElementById('quickACMEEmail');
+ const quickEnableMultiEl = document.getElementById('quickEnableMulti');
+ const quickEnableSocksEl = document.getElementById('quickEnableSocks');
+ const quickPresetGridEl = document.getElementById('quickPresetGrid');
+ const quickPresetSummaryEl = document.getElementById('quickPresetSummary');
+ const quickHostStatusEl = document.getElementById('quickHostStatus');
+ const quickStatusRailEl = document.getElementById('quickStatusRail');
+ const quickGuideEl = document.getElementById('quickGuide');
+ const quickInspectBtn = document.getElementById('quickInspectBtn');
+ const jumpInstallBtn = document.getElementById('jumpInstallBtn');
+ const jumpAdvancedBtn = document.getElementById('jumpAdvancedBtn');
+ const quickStartEl = document.getElementById('quickStart');
+ const advancedControlsEl = document.getElementById('advancedControls');
+ const accessPanelEl = document.getElementById('accessPanel');
+ const nodeStatusRailEl = document.getElementById('nodeStatusRail');
+ const nodeGuideEl = document.getElementById('nodeGuide');
+
+ function readTokenFromLocation() {
+ const url = new URL(window.location.href);
+ const queryToken = (url.searchParams.get('token') || '').trim();
+ if (queryToken) {
+ url.searchParams.delete('token');
+ history.replaceState({}, '', url.pathname + (url.searchParams.toString() ? '?' + url.searchParams.toString() : '') + url.hash.replace(/^#token=.*$/, ''));
+ return queryToken;
+ }
+ const hash = window.location.hash || '';
+ if (hash.startsWith('#token=')) {
+ const hashToken = hash.slice('#token='.length).trim();
+ history.replaceState({}, '', url.pathname + (url.search ? url.search : ''));
+ return hashToken;
+ }
+ return '';
+ }
+
+ const bootToken = readTokenFromLocation() || localStorage.getItem('vpnem_admin_token') || '';
+ tokenInput.value = bootToken;
+ if (bootToken) {
+ localStorage.setItem('vpnem_admin_token', bootToken);
+ accessPanelEl.open = false;
+ }
+ tokenInput.addEventListener('change', () => {
+ const token = tokenInput.value.trim();
+ localStorage.setItem('vpnem_admin_token', token);
+ accessPanelEl.open = !token;
+ });
+
+ function setStatus(text, tone = 'info') {
+ statusEl.textContent = text;
+ statusEl.className = 'status ' + tone;
+ }
+
+ function scrollToSection(el) {
+ if (!el) return;
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+
+ function openAdvancedControls() {
+ scrollToSection(advancedControlsEl);
+ }
+
+ function describeQuickPreset(preset) {
+ if (preset === 'multi+socks') return 'Сейчас выбран сценарий <strong>Сервер + SOCKS5</strong>.';
+ if (preset === 'socks') return 'Сейчас выбран сценарий <strong>Только SOCKS5</strong>.';
+ return 'Сейчас выбран сценарий <strong>Обычный сервер</strong>.';
+ }
+
+ function setQuickPreset(preset) {
+ const value = preset || 'multi';
+ quickEnableMultiEl.checked = value === 'multi' || value === 'multi+socks';
+ quickEnableSocksEl.checked = value === 'socks' || value === 'multi+socks';
+ if (quickPresetGridEl) {
+ quickPresetGridEl.querySelectorAll('[data-preset]').forEach(el => {
+ el.classList.toggle('active', el.getAttribute('data-preset') === value);
+ });
+ }
+ if (quickPresetSummaryEl) {
+ quickPresetSummaryEl.innerHTML = describeQuickPreset(value);
+ }
+ }
+
+ function updateCurrentSystem() {
+ const nodes = state.nodes || [];
+ const readyCount = nodes.filter(node => state.publishDecisions[node.id]?.eligible).length;
+ const blockedCount = nodes.filter(node => state.publishDecisions[node.id] && !state.publishDecisions[node.id].eligible).length;
+ const selected = nodes.find(node => node.id === state.selectedNodeID);
+ if (!nodes.length) {
+ currentSystemValueEl.textContent = 'Пока нет ни одного сервера.';
+ currentSystemMetaEl.textContent = 'Начните с проверки VPS, затем создайте первый сервер и скопируйте готовую ссылку.';
+ return;
+ }
+ currentSystemValueEl.textContent = 'Сохранено ' + String(nodes.length) + ' серверов. Из них ' + String(readyCount) + ' уже готовы к публикации, а ' + String(blockedCount) + ' требуют внимания.';
+ if (!selected) {
+ currentSystemMetaEl.textContent = 'Выберите любой сервер справа, и панель подскажет самый безопасный следующий шаг.';
+ return;
+ }
+ const selectedState = state.states[selected.id];
+ const selectedDecision = state.publishDecisions[selected.id];
+ const lifecycle = nodeLifecycleLabel(selected, selectedState, selectedDecision);
+ const publishLabel = selectedDecision?.eligible ? 'уже можно выдавать пользователям' : 'пока нужен дополнительный шаг';
+ currentSystemMetaEl.textContent = 'Сейчас выбран сервер «' + (selected.name || selected.id || 'Без имени') + '»: статус — ' + lifecycle.label + ', публикация — ' + publishLabel + '.';
+ }
+
+ function nodeProductState(node, runtimeState, decision) {
+ const status = String(runtimeState?.bootstrap_status || 'new');
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2');
+ const hasSocks = protocols.includes('socks5') || protocols.includes('socks');
+ if (node.enabled === false) {
+ return { title: 'Сервер выключен', subtitle: 'Он сохранён, но сейчас не публикуется.', nextStep: 'Включите его снова или удалите, если он больше не нужен.' };
+ }
+ if (status === 'healthy' || status === 'ready') {
+ if (decision?.eligible) {
+ if (hasMulti && !hasSocks) {
+ return { title: 'Сервер работает', subtitle: 'Основной MULTI уже поднят и ссылки готовы.', nextStep: 'При желании можно добавить SOCKS5 как запасной вариант.' };
+ }
+ if (hasMulti && hasSocks) {
+ return { title: 'Сервер полностью готов', subtitle: 'MULTI и SOCKS5 уже работают на одном VPS.', nextStep: 'Обычно здесь ничего делать не нужно.' };
+ }
+ if (hasSocks && !hasMulti) {
+ return { title: 'SOCKS5 сервер готов', subtitle: 'Прокси-сервер уже работает и доступен.', nextStep: 'Открывайте карточку чтобы скопировать ссылку или выполнить ремонт.' };
+ }
+ return { title: 'Сервер готов', subtitle: 'Его уже можно использовать и публиковать пользователям.', nextStep: 'Открывайте карточку только если нужен ремонт или переустановка.' };
+ }
+ return { title: 'Сервер почти готов', subtitle: 'Runtime уже работает, но публикация пока заблокирована.', nextStep: 'Откройте сервер и выполните рекомендуемое действие из подсказки ниже.' };
+ }
+ if (status === 'failed' || status === 'unreachable') {
+ return { title: 'Нужен ремонт', subtitle: 'Сервер сохранён, но сейчас не проходит проверку.', nextStep: 'Самое безопасное действие — «Починить сервер».' };
+ }
+ if (status === 'planned' || status === 'pending' || status === 'reachable') {
+ return { title: 'Идёт установка', subtitle: 'VPS уже в процессе настройки и проверки.', nextStep: 'Подождите немного или откройте карточку, если нужно повторить установку.' };
+ }
+ return { title: 'Сервер ещё не установлен', subtitle: 'Карточка сохранена, но runtime на VPS ещё не развёрнут.', nextStep: 'Откройте сервер и запустите установку или обновление.' };
+ }
+
+ function renderGuideBox(el, title, lines, tone = 'info') {
+ if (!el) return;
+ const safeLines = (lines || []).filter(Boolean);
+ if (!title && !safeLines.length) {
+ el.style.display = 'none';
+ el.innerHTML = '';
+ el.style.borderColor = 'var(--line)';
+ el.style.background = '#fff';
+ el.style.color = '';
+ return;
+ }
+ el.style.display = 'block';
+ el.className = 'muted-box';
+ el.style.borderColor = 'var(--line)';
+ el.style.background = '#fff';
+ el.style.color = '';
+ if (tone === 'warn') {
+ el.style.borderColor = '#f59e0b';
+ el.style.background = '#fffbeb';
+ el.style.color = '#92400e';
+ } else if (tone === 'danger') {
+ el.style.borderColor = '#dc2626';
+ el.style.background = '#fef2f2';
+ el.style.color = '#991b1b';
+ }
+ const titleHTML = title ? '<strong>' + title + '</strong>' : '';
+ const bodyHTML = safeLines.map(line => '<div>' + line + '</div>').join('');
+ el.innerHTML = titleHTML + bodyHTML;
+ }
+
+ function renderStatusRail(el, items) {
+ if (!el) return;
+ const safeItems = (items || []).filter(item => item && item.label);
+ if (!safeItems.length) {
+ el.style.display = 'none';
+ el.innerHTML = '';
+ return;
+ }
+ el.style.display = 'flex';
+ el.innerHTML = safeItems.map(item => '<span class="badge ' + (item.tone || 'idle') + '">' + item.label + '</span>').join('');
+ }
+
+ function quickStatusItems(data, existingNode) {
+ if (existingNode) {
+ const protocols = (existingNode.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const items = [{ label: 'Уже под управлением', tone: 'blocked' }];
+ if (protocols.includes('vless-reality') && protocols.includes('hysteria2')) items.push({ label: 'MULTI уже есть', tone: 'ready' });
+ if (protocols.includes('socks5') || protocols.includes('socks')) items.push({ label: 'SOCKS5 уже есть', tone: 'ready' });
+ return items;
+ }
+ if (!data) return [];
+ const items = [];
+ if (data.already_managed) items.push({ label: 'Уже под управлением', tone: 'blocked' });
+ if (data.quick_multi?.supported) items.push({ label: 'Можно ставить MULTI', tone: 'ready' });
+ else if (data.quick_multi) items.push({ label: 'MULTI заблокирован', tone: 'blocked' });
+ if (data.quick_socks5?.supported) items.push({ label: 'Можно ставить SOCKS5', tone: 'ready' });
+ else if (data.quick_socks5) items.push({ label: 'SOCKS5 заблокирован', tone: 'blocked' });
+ if (data.support_tier === 'recommended') items.push({ label: 'Рекомендуемый дистрибутив', tone: 'ready' });
+ else if (data.support_tier === 'supported') items.push({ label: 'Поддерживаемый дистрибутив', tone: 'active' });
+ else if (data.support_tier === 'experimental') items.push({ label: 'Экспериментальный дистрибутив', tone: 'blocked' });
+ return items;
+ }
+
+ function selectedNodeStatusItems(node, runtimeState, decision) {
+ if (!node) return [];
+ const items = [];
+ const lifecycle = nodeLifecycleLabel(node, runtimeState, decision);
+ items.push({ label: lifecycle.label, tone: lifecycle.tone });
+ if (decision?.eligible) items.push({ label: 'Готов к публикации', tone: 'ready' });
+ else if (decision) items.push({ label: 'Публикация заблокирована', tone: 'blocked' });
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ if (protocols.includes('vless-reality') && protocols.includes('hysteria2')) items.push({ label: 'MULTI', tone: 'active' });
+ if (protocols.includes('socks5') || protocols.includes('socks')) items.push({ label: 'SOCKS5', tone: 'active' });
+ return items;
+ }
+
+ function nodeMatchesFleetFilter(node) {
+ const filter = state.fleetFilter || 'all';
+ if (filter === 'all') return true;
+ const nodeState = state.states[node.id];
+ const decision = state.publishDecisions[node.id];
+ const lifecycle = nodeLifecycleLabel(node, nodeState, decision);
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2');
+ const hasSocks = protocols.includes('socks5') || protocols.includes('socks');
+ if (filter === 'ready') return lifecycle.label === 'Готов';
+ if (filter === 'repair') return lifecycle.label === 'Нужен ремонт' || lifecycle.label === 'Нужно внимание';
+ if (filter === 'managed') return true;
+ if (filter === 'multi') return hasMulti;
+ if (filter === 'socks5') return hasSocks;
+ return true;
+ }
+
+ function renderFleetFilters() {
+ if (!fleetFiltersEl) return;
+ fleetFiltersEl.querySelectorAll('[data-fleet-filter]').forEach(btn => {
+ const active = btn.getAttribute('data-fleet-filter') === state.fleetFilter;
+ btn.classList.toggle('alt', active);
+ btn.classList.toggle('ghost', !active);
+ });
+ }
+
+ function selectedNodeGuide(node, runtimeState, decision) {
+ if (!node) {
+ return {
+ title: 'Что можно сделать здесь',
+ tone: 'info',
+ lines: [
+ 'Сначала выберите узел в списке.',
+ 'Затем используйте основные действия, а не редактируйте поля вручную.',
+ ]
+ };
+ }
+ const status = String(runtimeState?.bootstrap_status || 'new');
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2');
+ const hasSocks = protocols.includes('socks5') || protocols.includes('socks');
+ if (node.enabled === false) {
+ return {
+ title: 'Этот узел выключен.',
+ tone: 'warn',
+ lines: [
+ 'Используйте «Включить узел», если хотите снова публиковать его.',
+ 'Используйте «Удалить сервер» только если хотите убрать его полностью.',
+ ]
+ };
+ }
+ if (status === 'healthy' || status === 'ready') {
+ const lines = ['Сейчас всё выглядит исправно. Используйте «Обновить сервер», если хотите применить актуальный bundle без смены секретов.'];
+ if (hasMulti && !hasSocks) {
+ lines.push('Используйте «Добавить SOCKS5», если хотите добавить fallback-прокси на порту 54101 на том же VPS.');
+ }
+ if (decision && decision.eligible === false) {
+ lines.push('Этот узел всё ещё заблокирован для публикации. Проверьте причины и затем нажмите «Починить сервер», если нужно.');
+ return { title: 'Узел работает, но ему нужно внимание.', tone: 'warn', lines: lines };
+ }
+ lines.push('Используйте «Переустановить сервер» только если хотите новые секреты и полный redeploy.');
+ return { title: 'Узел готов.', tone: 'info', lines: lines };
+ }
+ if (status === 'failed' || status === 'unreachable') {
+ return {
+ title: 'Узел требует ремонта.',
+ tone: 'danger',
+ lines: [
+ 'Сначала используйте «Починить сервер». Это сохранит текущую identity и заново развернёт runtime.',
+ 'Используйте «Переустановить сервер» только если ремонта недостаточно и нужны новые секреты.',
+ ]
+ };
+ }
+ if (status === 'planned' || status === 'pending' || status === 'reachable') {
+ return {
+ title: 'Узел ещё не развёрнут полностью.',
+ tone: 'warn',
+ lines: [
+ 'Используйте Bootstrap или «Подготовить узел», чтобы завершить установку.',
+ 'Затем запустите проверку перед публикацией ссылок пользователям.',
+ ]
+ };
+ }
+ return {
+ title: 'Узел сохранён, но ещё не развёрнут.',
+ tone: 'info',
+ lines: [
+ 'Используйте Bootstrap, чтобы установить runtime на VPS.',
+ hasMulti && !hasSocks ? 'Позже используйте «Добавить SOCKS5», если захотите добавить fallback-прокси.' : '',
+ ]
+ };
+ }
+
+ function nodeLifecycleLabel(node, runtimeState, decision) {
+ if (node && node.enabled === false) return { label: 'Выключен', tone: 'idle' };
+ const status = String(runtimeState?.bootstrap_status || 'new');
+ if (status === 'healthy' || status === 'ready') return { label: 'Готов', tone: 'ready' };
+ if (status === 'failed' || status === 'unreachable') return { label: 'Нужен ремонт', tone: 'blocked' };
+ if (status === 'planned' || status === 'pending' || status === 'reachable') return { label: 'Устанавливается', tone: 'active' };
+ if (decision && decision.eligible === false) return { label: 'Нужно внимание', tone: 'blocked' };
+ return { label: 'Не развёрнут', tone: 'idle' };
+ }
+
+ function encodeBase64(value) {
+ return btoa(unescape(encodeURIComponent(value)));
+ }
+
+ function buildVmessLink(node, protocol) {
+ const payload = {
+ v: '2',
+ ps: node.id || node.name || 'vmess',
+ add: node.domain || node.host || '',
+ port: String(protocol.port || 0),
+ id: String(protocol.auth?.uuid || ''),
+ aid: '0',
+ scy: 'auto',
+ net: 'ws',
+ type: 'none',
+ host: String(protocol.tls?.server_name || node.domain || ''),
+ path: String(protocol.extra?.path || '/vmess'),
+ tls: protocol.tls?.enabled ? 'tls' : '',
+ sni: String(protocol.tls?.server_name || node.domain || '')
+ };
+ return 'vmess://' + encodeBase64(JSON.stringify(payload));
+ }
+
+ function buildShadowsocksLink(node, protocol) {
+ const userinfo = encodeBase64(String(protocol.auth?.method || '') + ':' + String(protocol.auth?.password || ''));
+ return 'ss://' + userinfo + '@' + (node.domain || node.host || '') + ':' + String(protocol.port || 0) + '#' + encodeURIComponent(node.id || 'shadowsocks');
+ }
+
+ function buildHysteria2Link(node, protocol) {
+ const params = new URLSearchParams();
+ params.set('sni', String(node.domain || protocol.tls?.server_name || ''));
+ if (protocol.extra?.obfs_password) {
+ params.set('obfs', 'salamander');
+ params.set('obfs-password', String(protocol.extra.obfs_password));
+ }
+ params.set('insecure', '1');
+ return 'hysteria2://' + encodeURIComponent(String(protocol.auth?.password || '')) + '@' + (node.domain || node.host || '') + ':' + String(protocol.port || 0) + '/?' + params.toString() + '#' + encodeURIComponent(node.id || 'hysteria2');
+ }
+
+ function protocolCards(node, runtimeState) {
+ if (!node) return [];
+ const host = node.domain || node.host || '';
+ return (node.protocols || []).filter(p => p.enabled).map(protocol => {
+ const titleMap = {
+ 'socks5': 'SOCKS5',
+ 'socks': 'SOCKS5',
+ 'vless-reality': 'VLESS REALITY'
+ };
+ const card = {
+ type: protocol.type,
+ title: titleMap[protocol.type] || protocol.type.charAt(0).toUpperCase() + protocol.type.slice(1),
+ subtitle: host + ':' + String(protocol.port || 0),
+ uri: '',
+ details: []
+ };
+ if (runtimeState?.bootstrap_status) {
+ card.details.push('Node Status: ' + runtimeState.bootstrap_status);
+ }
+ if (protocol.type === 'vless') {
+ card.uri = buildVlessHint(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'UUID: ' + String(protocol.auth?.uuid || ''),
+ 'TLS: ' + String(Boolean(protocol.tls?.enabled)),
+ 'SNI: ' + String(protocol.tls?.server_name || ''),
+ 'Path: ' + String(protocol.extra?.path || '/ws'),
+ );
+ } else if (protocol.type === 'vless-reality') {
+ card.uri = buildRealityLink(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'UUID: ' + String(protocol.auth?.uuid || ''),
+ 'SNI: ' + String(protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com'),
+ 'Fingerprint: ' + String(protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome'),
+ 'Public Key: ' + String(protocol.tls?.reality?.public_key || protocol.reality?.public_key || ''),
+ 'Short ID: ' + String(protocol.tls?.reality?.short_id || protocol.reality?.short_id || ''),
+ );
+ } else if (protocol.type === 'shadowsocks') {
+ card.uri = buildShadowsocksLink(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'Method: ' + String(protocol.auth?.method || ''),
+ 'Password: ' + String(protocol.auth?.password || ''),
+ );
+ } else if (protocol.type === 'vmess') {
+ card.uri = buildVmessLink(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'UUID: ' + String(protocol.auth?.uuid || ''),
+ 'TLS: ' + String(Boolean(protocol.tls?.enabled)),
+ 'Path: ' + String(protocol.extra?.path || '/vmess'),
+ );
+ } else if (protocol.type === 'hysteria2') {
+ const up = Number(protocol.extra?.up_mbps || 0);
+ const down = Number(protocol.extra?.down_mbps || 0);
+ card.uri = buildHysteria2Link(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'Password: ' + String(protocol.auth?.password || ''),
+ 'Obfs Password: ' + String(protocol.extra?.obfs_password || ''),
+ 'Bandwidth Hint: ' + (up > 0 || down > 0 ? ('up ' + (up || 'auto') + ' / down ' + (down || 'auto') + ' Mbps') : 'auto'),
+ );
+ } else if (protocol.type === 'socks5' || protocol.type === 'socks') {
+ card.uri = 'socks5://' + host + ':' + String(protocol.port || 0);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ );
+ }
+ return card;
+ });
+ }
+
+ function renderReadyCards(node, runtimeState) {
+ readyCardsEl.innerHTML = '';
+ const cards = protocolCards(node, runtimeState);
+ if (!cards.length) {
+ readyCardsEl.innerHTML = '<div class="muted-box">Выберите узел с включёнными протоколами, чтобы увидеть здесь готовые ссылки для копирования.</div>';
+ return;
+ }
+ cards.forEach(card => {
+ const detailText = card.details.join('\n');
+ const el = document.createElement('div');
+ el.className = 'ready-card';
+ el.innerHTML =
+ '<div class="ready-card-head">' +
+ '<div>' +
+ '<div class="ready-card-title">' + card.title + '</div>' +
+ '<div class="ready-card-sub">' + card.subtitle + '</div>' +
+ '</div>' +
+ '<div class="toolbar">' +
+ (card.uri ? '<button class="ghost" type="button" data-copy="' + encodeURIComponent(card.uri) + '">Копировать URI</button>' : '') +
+ '<button class="ghost" type="button" data-copy="' + encodeURIComponent(detailText) + '">Копировать детали</button>' +
+ '</div>' +
+ '</div>' +
+ (card.uri ? '<div class="mono">' + card.uri + '</div>' : '') +
+ '<div class="mono">' + detailText.replace(/\n/g, '<br>') + '</div>';
+ readyCardsEl.appendChild(el);
+ });
+ }
+
+ function buildVlessHint(node, protocol) {
+ const host = node.domain || node.host || '';
+ const path = String(protocol.extra?.path || '/ws');
+ const sni = String(protocol.tls?.server_name || node.domain || '');
+ return 'vless://' + String(protocol.auth?.uuid || '') + '@' + host + ':' + String(protocol.port || 0) + '?security=' + (protocol.tls?.enabled ? 'tls' : 'none') + '&type=ws&path=' + encodeURIComponent(path) + '&sni=' + encodeURIComponent(sni) + '#' + encodeURIComponent(node.id || 'vless');
+ }
+
+ function buildRealityLink(node, protocol) {
+ const host = node.domain || node.host || '';
+ const sni = String(protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com');
+ const fp = String(protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome');
+ const pbk = String(protocol.tls?.reality?.public_key || protocol.reality?.public_key || '');
+ const sid = String(protocol.tls?.reality?.short_id || protocol.reality?.short_id || '');
+ return 'vless://' + String(protocol.auth?.uuid || '') + '@' + host + ':' + String(protocol.port || 0) + '?encryption=none&security=reality&sni=' + encodeURIComponent(sni) + '&fp=' + encodeURIComponent(fp) + '&pbk=' + encodeURIComponent(pbk) + '&sid=' + encodeURIComponent(sid) + '&type=tcp#' + encodeURIComponent(node.id || 'vless-reality');
+ }
+
+ function protocolSummary(node, runtimeState) {
+ if (!node) return 'No node selected.';
+ const lines = [];
+ lines.push('Node: ' + (node.name || node.id || ''));
+ lines.push('Host: ' + (node.domain || node.host || ''));
+ if (runtimeState?.bootstrap_status) {
+ lines.push('Status: ' + runtimeState.bootstrap_status);
+ }
+ lines.push('');
+ for (const protocol of (node.protocols || []).filter(p => p.enabled)) {
+ if (protocol.type === 'vless') {
+ lines.push('VLESS');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' uuid: ' + (protocol.auth?.uuid || ''));
+ lines.push(' tls: ' + String(Boolean(protocol.tls?.enabled)));
+ lines.push(' server_name: ' + (protocol.tls?.server_name || ''));
+ lines.push(' path: ' + (protocol.extra?.path || ''));
+ lines.push(' uri: ' + buildVlessHint(node, protocol));
+ } else if (protocol.type === 'vless-reality') {
+ lines.push('VLESS REALITY');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' uuid: ' + (protocol.auth?.uuid || ''));
+ lines.push(' server_name: ' + (protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com'));
+ lines.push(' public_key: ' + (protocol.tls?.reality?.public_key || protocol.reality?.public_key || ''));
+ lines.push(' short_id: ' + (protocol.tls?.reality?.short_id || protocol.reality?.short_id || ''));
+ lines.push(' fingerprint: ' + (protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome'));
+ lines.push(' uri: ' + buildRealityLink(node, protocol));
+ } else if (protocol.type === 'shadowsocks') {
+ lines.push('Shadowsocks');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' method: ' + (protocol.auth?.method || ''));
+ lines.push(' password: ' + (protocol.auth?.password || ''));
+ lines.push(' uri: ' + buildShadowsocksLink(node, protocol));
+ } else if (protocol.type === 'socks5' || protocol.type === 'socks') {
+ lines.push('SOCKS5');
+ lines.push(' server: ' + (node.host || ''));
+ lines.push(' port: ' + protocol.port);
+ } else if (protocol.type === 'vmess') {
+ lines.push('VMess');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' uuid: ' + (protocol.auth?.uuid || ''));
+ lines.push(' tls: ' + String(Boolean(protocol.tls?.enabled)));
+ lines.push(' path: ' + (protocol.extra?.path || '/vmess'));
+ lines.push(' uri: ' + buildVmessLink(node, protocol));
+ } else if (protocol.type === 'hysteria2') {
+ lines.push('Hysteria2');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' password: ' + (protocol.auth?.password || ''));
+ lines.push(' obfs_password: ' + (protocol.extra?.obfs_password || ''));
+ lines.push(' uri: ' + buildHysteria2Link(node, protocol));
+ }
+ lines.push('');
+ }
+ return lines.join('\n').trim();
+ }
+
+ async function api(path, options = {}) {
+ const headers = new Headers(options.headers || {});
+ const token = tokenInput.value.trim();
+ if (token) headers.set('X-Admin-Token', token);
+ if (options.body && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json');
+ }
+ const res = await fetch(path, { ...options, headers });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || res.statusText);
+ }
+ return res.json();
+ }
+
+ function currentActionPayload() {
+ const auth = String(form.ssh_auth.value || 'key');
+ if (auth === 'password') {
+ return { ssh_password: String(form.ssh_runtime_password.value || '').trim() };
+ }
+ return {};
+ }
+
+ function currentNodeFromForm() {
+ const fd = new FormData(form);
+ const protocols = [];
+ if (fd.get('vless_enabled') === 'true') {
+ protocols.push({
+ type: 'vless',
+ enabled: true,
+ port: Number(fd.get('vless_port')),
+ tls: {
+ enabled: fd.get('vless_tls_enabled') === 'true',
+ server_name: String(fd.get('vless_server_name') || '')
+ },
+ auth: {
+ uuid: String(fd.get('vless_uuid') || '')
+ },
+ extra: {
+ transport_type: String(fd.get('vless_transport_type') || ''),
+ path: String(fd.get('vless_path') || '')
+ }
+ });
+ }
+ if (fd.get('reality_enabled') === 'true') {
+ protocols.push({
+ type: 'vless-reality',
+ enabled: true,
+ port: Number(fd.get('reality_port')),
+ tls: {
+ enabled: true,
+ server_name: String(fd.get('reality_server_name') || '')
+ },
+ auth: {
+ uuid: String(fd.get('reality_uuid') || '')
+ },
+ reality: {
+ server_name: String(fd.get('reality_server_name') || ''),
+ server_port: Number(fd.get('reality_server_port') || 443),
+ public_key: String(fd.get('reality_public_key') || ''),
+ private_key: String(fd.get('reality_private_key') || ''),
+ short_id: String(fd.get('reality_short_id') || ''),
+ fingerprint: String(fd.get('reality_fingerprint') || '')
+ }
+ });
+ }
+ if (fd.get('ss_enabled') === 'true') {
+ protocols.push({
+ type: 'shadowsocks',
+ enabled: true,
+ port: Number(fd.get('ss_port')),
+ auth: {
+ method: String(fd.get('ss_method') || ''),
+ password: String(fd.get('ss_password') || '')
+ }
+ });
+ }
+ if (fd.get('socks_enabled') === 'true') {
+ protocols.push({
+ type: 'socks5',
+ enabled: true,
+ port: Number(fd.get('socks_port'))
+ });
+ }
+ if (fd.get('vmess_enabled') === 'true') {
+ protocols.push({
+ type: 'vmess',
+ enabled: true,
+ port: Number(fd.get('vmess_port')),
+ tls: {
+ enabled: fd.get('vmess_tls_enabled') === 'true',
+ server_name: String(fd.get('vmess_server_name') || '')
+ },
+ auth: {
+ uuid: String(fd.get('vmess_uuid') || '')
+ },
+ extra: {
+ path: String(fd.get('vmess_path') || '')
+ }
+ });
+ }
+ if (fd.get('hy2_enabled') === 'true') {
+ protocols.push({
+ type: 'hysteria2',
+ enabled: true,
+ port: Number(fd.get('hy2_port')),
+ auth: {
+ password: String(fd.get('hy2_password') || '')
+ },
+ extra: {
+ up_mbps: Number(fd.get('hy2_up_mbps') || 0),
+ down_mbps: Number(fd.get('hy2_down_mbps') || 0),
+ obfs_password: String(fd.get('hy2_obfs_password') || ''),
+ tls_cert_path: String(fd.get('hy2_tls_cert_path') || ''),
+ tls_key_path: String(fd.get('hy2_tls_key_path') || '')
+ }
+ });
+ }
+ return {
+ id: String(fd.get('id') || ''),
+ name: String(fd.get('name') || ''),
+ provider: String(fd.get('provider') || ''),
+ region: String(fd.get('region') || ''),
+ host: String(fd.get('host') || ''),
+ domain: String(fd.get('domain') || ''),
+ acme_email: String(fd.get('acme_email') || ''),
+ enabled: fd.get('enabled') === 'true',
+ ssh: {
+ user: String(fd.get('ssh_user') || ''),
+ port: Number(fd.get('ssh_port') || 22),
+ auth: String(fd.get('ssh_auth') || 'key'),
+ identity_file: String(fd.get('ssh_identity') || ''),
+ password_env: String(fd.get('ssh_password_env') || '')
+ },
+ protocols
+ };
+ }
+
+ function fillForm(node) {
+ state.selectedNodeID = node.id || '';
+ form.id.value = node.id || '';
+ form.name.value = node.name || '';
+ form.provider.value = node.provider || 'custom-vps';
+ form.region.value = node.region || '';
+ form.enabled.value = String(Boolean(node.enabled));
+ form.host.value = node.host || '';
+ form.domain.value = node.domain || '';
+ form.acme_email.value = node.acme_email || '';
+ form.ssh_user.value = node.ssh?.user || 'root';
+ form.ssh_port.value = node.ssh?.port || 22;
+ form.ssh_auth.value = node.ssh?.auth || 'key';
+ form.ssh_identity.value = node.ssh?.identity_file || '';
+ form.ssh_password_env.value = node.ssh?.password_env || '';
+ form.ssh_runtime_password.value = '';
+
+ const vless = (node.protocols || []).find(p => p.type === 'vless');
+ form.vless_enabled.value = String(Boolean(vless?.enabled));
+ form.vless_port.value = vless?.port || 443;
+ form.vless_uuid.value = vless?.auth?.uuid || '';
+ form.vless_tls_enabled.value = String(Boolean(vless?.tls?.enabled));
+ form.vless_server_name.value = vless?.tls?.server_name || '';
+ form.vless_transport_type.value = vless?.extra?.transport_type || '';
+ form.vless_path.value = vless?.extra?.path || '';
+
+ const reality = (node.protocols || []).find(p => p.type === 'vless-reality');
+ form.reality_enabled.value = String(Boolean(reality?.enabled));
+ form.reality_port.value = reality?.port || 443;
+ form.reality_uuid.value = reality?.auth?.uuid || '';
+ form.reality_server_name.value = reality?.reality?.server_name || reality?.tls?.server_name || 'www.nokia.com';
+ form.reality_server_port.value = reality?.reality?.server_port || 443;
+ form.reality_public_key.value = reality?.reality?.public_key || reality?.tls?.reality?.public_key || '';
+ form.reality_private_key.value = reality?.reality?.private_key || '';
+ form.reality_short_id.value = reality?.reality?.short_id || reality?.tls?.reality?.short_id || '';
+ form.reality_fingerprint.value = reality?.reality?.fingerprint || reality?.tls?.reality?.fingerprint || 'chrome';
+
+ const ss = (node.protocols || []).find(p => p.type === 'shadowsocks');
+ form.ss_enabled.value = String(Boolean(ss?.enabled));
+ form.ss_port.value = ss?.port || 8443;
+ form.ss_method.value = ss?.auth?.method || '';
+ form.ss_password.value = ss?.auth?.password || '';
+
+ const socks = (node.protocols || []).find(p => p.type === 'socks5' || p.type === 'socks');
+ form.socks_enabled.value = String(Boolean(socks?.enabled));
+ form.socks_port.value = socks?.port || 1080;
+
+ const vmess = (node.protocols || []).find(p => p.type === 'vmess');
+ form.vmess_enabled.value = String(Boolean(vmess?.enabled));
+ form.vmess_port.value = vmess?.port || 443;
+ form.vmess_uuid.value = vmess?.auth?.uuid || '';
+ form.vmess_tls_enabled.value = String(Boolean(vmess?.tls?.enabled));
+ form.vmess_server_name.value = vmess?.tls?.server_name || '';
+ form.vmess_path.value = vmess?.extra?.path || '';
+
+ const hy2 = (node.protocols || []).find(p => p.type === 'hysteria2');
+ form.hy2_enabled.value = String(Boolean(hy2?.enabled));
+ form.hy2_port.value = hy2?.port || 8443;
+ form.hy2_password.value = hy2?.auth?.password || '';
+ form.hy2_up_mbps.value = hy2?.extra?.up_mbps || '';
+ form.hy2_down_mbps.value = hy2?.extra?.down_mbps || '';
+ form.hy2_obfs_password.value = hy2?.extra?.obfs_password || '';
+ form.hy2_tls_cert_path.value = hy2?.extra?.tls_cert_path || '';
+ form.hy2_tls_key_path.value = hy2?.extra?.tls_key_path || '';
+ summaryViewEl.textContent = protocolSummary(node, state.states[node.id]);
+ renderReadyCards(node, state.states[node.id]);
+ renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(node, state.states[node.id], state.publishDecisions[node.id]));
+ const guide = selectedNodeGuide(node, state.states[node.id], state.publishDecisions[node.id]);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ updateCurrentSystem();
+ openAdvancedControls();
+ loadNodeState(node.id).catch(() => {});
+ }
+
+ function currentNodeID() {
+ return String(form.id.value || '').trim();
+ }
+
+ function normalizeHost(value) {
+ return String(value || '').trim().toLowerCase().replace(/\.$/, '');
+ }
+
+ function findExistingNodeByHost(host) {
+ const needle = normalizeHost(host);
+ if (!needle) return null;
+ return state.nodes.find(node => normalizeHost(node.host) === needle) || null;
+ }
+
+ function updateQuickHostStatus() {
+ const existing = findExistingNodeByHost(quickHostEl.value);
+ if (!existing) {
+ quickHostStatusEl.style.display = 'none';
+ quickHostStatusEl.textContent = '';
+ renderStatusRail(quickStatusRailEl, []);
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', [
+ 'Сначала нажмите «Проверить VPS», чтобы понять, подходит ли сервер для MULTI или SOCKS5.',
+ 'Затем нажимайте «Создать прокси» только если панель показывает, что VPS готов.',
+ ]);
+ return;
+ }
+ const protocols = (existing.protocols || []).filter(p => p.enabled).map(p => p.type).join(', ') || 'no enabled protocols';
+ quickHostStatusEl.style.display = 'block';
+ quickHostStatusEl.innerHTML = '<strong>Этот VPS уже под управлением.</strong><br>Узел <code>' + existing.id + '</code> уже использует этот хост со следующими протоколами: ' + protocols + '. Откройте его в настройках и используйте обновление или переустановку вместо создания второго quick-узла.';
+ renderStatusRail(quickStatusRailEl, quickStatusItems(null, existing));
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', [
+ 'Не создавайте второй quick-узел на этом VPS.',
+ 'Откройте существующий узел в настройках.',
+ protocols.includes('vless-reality') && protocols.includes('hysteria2') && !protocols.includes('socks5')
+ ? 'Если нужен fallback-прокси, используйте «Добавить SOCKS5».'
+ : 'Используйте обновление или нужный вариант переустановки в зависимости от проблемы.',
+ ], 'warn');
+ }
+
+ function renderQuickPreflight(data) {
+ if (!data) {
+ quickHostStatusEl.style.display = 'none';
+ renderStatusRail(quickStatusRailEl, []);
+ renderGuideBox(quickGuideEl, '', []);
+ return;
+ }
+ const warnings = Array.isArray(data.warnings) ? data.warnings : [];
+ const multi = data.quick_multi || {};
+ const socks = data.quick_socks5 || {};
+ const capabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
+ const suggestedMulti = data.suggested_multi_name || '';
+ const suggestedSocks = data.suggested_socks_name || '';
+ const managed = data.already_managed ? '<strong>Этот VPS уже управляется vpnem.</strong><br>' : '';
+ const hostState = data.host_state_label ? '<strong>Статус VPS:</strong> ' + data.host_state_label + '<br>' : '';
+ const multiLine = 'MULTI: ' + (multi.supported ? 'готов' : 'заблокирован') + (multi.reasons && multi.reasons.length ? ' — ' + multi.reasons.join(' · ') : '');
+ const socksLine = 'SOCKS5: ' + (socks.supported ? 'готов' : 'заблокирован') + (socks.reasons && socks.reasons.length ? ' — ' + socks.reasons.join(' · ') : '');
+ const portLine = 'Порты — tcp/443: ' + (data.ports?.tcp_443 || 'unknown') + ', udp/443: ' + (data.ports?.udp_443 || 'unknown') + ', tcp/54101: ' + (data.ports?.tcp_54101 || 'unknown');
+ const capsLine = capabilities.length ? 'Возможности — ' + capabilities.join(' · ') : '';
+ const nameLine = suggestedMulti || suggestedSocks ? 'Автоимена — MULTI: ' + (suggestedMulti || 'auto') + (suggestedSocks ? ' · SOCKS5: ' + suggestedSocks : '') : '';
+ const warningLine = warnings.length ? '<br><span style="color:#b45309">' + warnings.join(' ') + '</span>' : '';
+ quickHostStatusEl.style.display = 'block';
+ quickHostStatusEl.innerHTML =
+ managed +
+ hostState +
+ '<strong>' + (data.os_pretty || data.os_id || 'Неизвестный Linux') + '</strong> · уровень поддержки: <strong>' + (data.support_tier || 'unknown') + '</strong><br>' +
+ portLine + '<br>' +
+ (capsLine ? capsLine + '<br>' : '') +
+ (nameLine ? nameLine + '<br>' : '') +
+ multiLine + '<br>' +
+ socksLine +
+ (data.recommended_action ? '<br><strong>Следующий шаг:</strong> ' + data.recommended_action : '') +
+ warningLine;
+ renderStatusRail(quickStatusRailEl, quickStatusItems(data, null));
+ const guideLines = [];
+ if (data.already_managed) {
+ guideLines.push('Этот VPS уже используется одной из нод vpnem.');
+ guideLines.push('Откройте существующий узел в настройках вместо создания нового quick-узла.');
+ if (socks.supported && !multi.supported) {
+ guideLines.push('Если нужен только простой fallback-прокси, здесь безопаснее добавить SOCKS5.');
+ }
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'warn');
+ return;
+ }
+ if (multi.supported) {
+ guideLines.push('Этот VPS выглядит безопасным для стандартной установки MULTI.');
+ guideLines.push('Нажмите «Создать прокси», если хотите TCP через REALITY и UDP через Hysteria2.');
+ if (suggestedMulti) {
+ guideLines.push('Имя узла будет создано автоматически, например: ' + suggestedMulti + '.');
+ }
+ if (socks.supported) {
+ guideLines.push('Выбирайте SOCKS5 только если хотите более простой прокси без multi-транспортной схемы.');
+ }
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines);
+ return;
+ }
+ if (socks.supported) {
+ guideLines.push('Сейчас MULTI на этом VPS заблокирован.');
+ guideLines.push('Здесь безопасным quick-вариантом является SOCKS5.');
+ if (suggestedSocks) {
+ guideLines.push('Имя SOCKS5-узла будет создано автоматически, например: ' + suggestedSocks + '.');
+ }
+ guideLines.push('Если позже понадобится MULTI, сначала освободите tcp/443 и udp/443.');
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'warn');
+ return;
+ }
+ guideLines.push('Быстрая установка на этом VPS сейчас заблокирована.');
+ guideLines.push(data.recommended_action || 'Сначала исправьте найденные конфликты, затем снова выполните проверку.');
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'danger');
+ }
+
+ async function loadNodeState(nodeID) {
+ if (!nodeID) {
+ stateViewEl.textContent = '';
+ return;
+ }
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/state');
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ const node = state.nodes.find(item => item.id === nodeID);
+ if (node) {
+ state.states[nodeID] = data;
+ summaryViewEl.textContent = protocolSummary(node, data);
+ renderReadyCards(node, data);
+ renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(node, data, state.publishDecisions[node.id]));
+ const guide = selectedNodeGuide(node, data, state.publishDecisions[node.id]);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ }
+ } catch (error) {
+ stateViewEl.textContent = 'Сохранённого состояния пока нет.';
+ }
+ }
+
+ function renderNodes() {
+ nodeListEl.innerHTML = '';
+ renderFleetFilters();
+ if (!state.nodes.length) {
+ nodeListEl.innerHTML = '<div class="muted-box empty-box">Пока нет ни одного узла. Используйте <strong>Быструю установку</strong>, чтобы превратить VPS с IP и паролем в опубликованный узел.</div>';
+ updateCurrentSystem();
+ return;
+ }
+
+ const visibleNodes = state.nodes.filter(node => nodeMatchesFleetFilter(node));
+ if (!visibleNodes.length) {
+ nodeListEl.innerHTML = '<div class="muted-box empty-box">Сейчас ни один узел не подходит под этот фильтр.</div>';
+ updateCurrentSystem();
+ return;
+ }
+
+ visibleNodes.forEach(node => {
+ const el = document.createElement('button');
+ el.type = 'button';
+ el.className = 'node-card';
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type).join(', ') || 'no enabled protocols';
+ const nodeState = state.states[node.id];
+ const decision = state.publishDecisions[node.id];
+ const publicHost = nodeState?.public_host || node.domain || node.host;
+ const publishReady = decision ? Boolean(decision.eligible) : false;
+ const publishLabel = publishReady ? 'Готов к публикации' : 'Нужно внимание';
+ const publishClass = publishReady ? 'ready' : 'blocked';
+ const lifecycle = nodeLifecycleLabel(node, nodeState, decision);
+ const product = nodeProductState(node, nodeState, decision);
+ const protocolBadges = (node.protocols || []).filter(p => p.enabled).map(p => '<span class="badge protocol">' + p.type + '</span>').join('');
+ const reasons = decision && decision.reasons && decision.reasons.length
+ ? '<div class="node-meta" style="color:#b45309">Почему нужен шаг: ' + decision.reasons.join(' · ') + '</div>'
+ : '';
+ el.innerHTML =
+ '<div class="node-header">' +
+ '<div>' +
+ '<div class="node-title">' + product.title + '</div>' +
+ '<div class="node-meta" style="margin-top:4px">' + (node.name || node.id) + '</div>' +
+ '<div class="node-meta">' + [node.region, publicHost].filter(Boolean).join(' · ') + '</div>' +
+ '</div>' +
+ '<div class="badges">' +
+ '<span class="badge ' + publishClass + '">' + publishLabel + '</span>' +
+ '<span class="badge ' + lifecycle.tone + '">' + lifecycle.label + '</span>' +
+ '</div>' +
+ '</div>' +
+ '<div class="muted-box" style="padding:12px">' +
+ '<strong>' + product.subtitle + '</strong><br>' +
+ product.nextStep +
+ '</div>' +
+ '<div class="badges">' + protocolBadges + '</div>' +
+ reasons;
+ if (state.selectedNodeID && state.selectedNodeID === node.id) {
+ el.style.borderColor = 'var(--accent)';
+ el.style.boxShadow = '0 0 0 2px rgba(15, 118, 110, 0.14)';
+ }
+ el.addEventListener('click', () => fillForm(node));
+ nodeListEl.appendChild(el);
+ });
+ updateCurrentSystem();
+ }
+
+ async function loadNodes() {
+ setStatus('Загрузка узлов...', 'info');
+ const data = await api('/api/v1/control/nodes');
+ state.nodes = data.nodes || [];
+ state.states = data.states || {};
+ state.publishDecisions = data.publish_decisions || {};
+ renderNodes();
+ updateQuickHostStatus();
+ if (state.selectedNodeID) {
+ const selectedNode = state.nodes.find(node => node.id === state.selectedNodeID);
+ if (selectedNode) {
+ summaryViewEl.textContent = protocolSummary(selectedNode, state.states[selectedNode.id]);
+ renderReadyCards(selectedNode, state.states[selectedNode.id]);
+ renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(selectedNode, state.states[selectedNode.id], state.publishDecisions[selectedNode.id]));
+ const guide = selectedNodeGuide(selectedNode, state.states[selectedNode.id], state.publishDecisions[selectedNode.id]);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ }
+ } else {
+ renderStatusRail(nodeStatusRailEl, []);
+ const guide = selectedNodeGuide(null, null, null);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ }
+ setStatus('Узлы загружены.', 'success');
+ }
+
+ form.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ setStatus('Сохранение узла...', 'info');
+ try {
+ const payload = currentNodeFromForm();
+ await api('/api/v1/control/nodes', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ });
+ state.selectedNodeID = payload.id;
+ await loadNodes();
+ await loadNodeState(payload.id);
+ setStatus('Узел сохранён.', 'success');
+ } catch (error) {
+ setStatus('Не удалось сохранить узел: ' + error.message, 'error');
+ }
+ });
+
+ async function runNodeAction(action, dryRun = false) {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus(action + '...', 'info');
+ try {
+ const suffix = dryRun ? '?dry_run=true' : '';
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action + suffix, {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ await loadNodes();
+ await loadNodeState(nodeID);
+ setStatus('Действие завершено.', 'success');
+ } catch (error) {
+ setStatus('Ошибка действия: ' + error.message, 'error');
+ }
+ }
+
+ async function runSimpleNodeAction(action, successMessage) {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus(action + '...', 'info');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action, {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ if (data.node) {
+ fillForm(data.node);
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ await loadNodes();
+ if (data.node?.id) {
+ await loadNodeState(data.node.id);
+ } else if (nodeID) {
+ await loadNodeState(nodeID).catch(() => {});
+ }
+ setStatus(successMessage);
+ } catch (error) {
+ setStatus('Ошибка действия: ' + error.message);
+ }
+ }
+
+ async function runDNSAction(action) {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus(action + '...');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action, {
+ method: 'POST'
+ });
+ if (data.node) {
+ fillForm(data.node);
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ setStatus('Действие завершено.', 'success');
+ await loadNodes();
+ } catch (error) {
+ setStatus('Ошибка действия: ' + error.message, 'error');
+ }
+ }
+
+ async function runProvisionNode() {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus('Подготовка узла...', 'info');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/provision', {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ if (data.dns?.fqdn) {
+ form.domain.value = data.dns.fqdn;
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ state.selectedNodeID = nodeID;
+ setStatus(data.ready_for_catalog ? 'Подготовка завершена, каталог опубликован.' : 'Подготовка завершена, но публикация была пропущена.', data.ready_for_catalog ? 'success' : 'error');
+ await loadNodes();
+ await loadNodeState(nodeID);
+ } catch (error) {
+ setStatus('Подготовка завершилась ошибкой: ' + error.message, 'error');
+ }
+ }
+
+ async function runQuickProvision() {
+ const host = String(quickHostEl.value || '').trim();
+ const rootPassword = String(quickRootPasswordEl.value || '').trim();
+ if (!host || !rootPassword) {
+ setStatus('Нужны host и root-пароль.');
+ return;
+ }
+ const existing = findExistingNodeByHost(host);
+ if (existing) {
+ fillForm(existing);
+ openAdvancedControls();
+ setStatus('Этот VPS уже управляется узлом ' + existing.id + '. Используйте настройки и действия вместо создания второго quick-узла.', 'error');
+ return;
+ }
+ setStatus('Быстрое создание...', 'info');
+ try {
+ const data = await api('/api/v1/control/quick-provision', {
+ method: 'POST',
+ body: JSON.stringify({
+ host,
+ root_password: rootPassword,
+ region: String(quickRegionEl.value || '').trim(),
+ provider: String(quickProviderEl.value || '').trim(),
+ acme_email: String(quickACMEEmailEl.value || '').trim(),
+ enable_multi: quickEnableMultiEl.checked,
+ enable_socks5: quickEnableSocksEl.checked,
+ })
+ });
+ if (data.node) {
+ fillForm(data.node);
+ state.selectedNodeID = data.node.id || '';
+ form.ssh_auth.value = 'password';
+ form.ssh_password_env.value = '';
+ form.ssh_runtime_password.value = rootPassword;
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ setStatus(data.ready_for_catalog ? 'Быстрое создание завершено, каталог опубликован.' : 'Быстрое создание завершено, но публикация была пропущена.', data.ready_for_catalog ? 'success' : 'error');
+ quickRootPasswordEl.value = '';
+ await loadNodes();
+ if (data.node?.id) {
+ await loadNodeState(data.node.id);
+ }
+ } catch (error) {
+ setStatus('Быстрое создание завершилось ошибкой: ' + error.message, 'error');
+ }
+ }
+
+ async function runQuickInspect() {
+ const host = String(quickHostEl.value || '').trim();
+ const rootPassword = String(quickRootPasswordEl.value || '').trim();
+ if (!host || !rootPassword) {
+ setStatus('Для проверки VPS нужны host и root-пароль.', 'error');
+ return;
+ }
+ setStatus('Проверка VPS...', 'info');
+ try {
+ const data = await api('/api/v1/control/preflight', {
+ method: 'POST',
+ body: JSON.stringify({
+ host,
+ root_password: rootPassword,
+ region: String(quickRegionEl.value || '').trim(),
+ provider: String(quickProviderEl.value || '').trim()
+ })
+ });
+ renderQuickPreflight(data);
+ setStatus('Проверка VPS завершена.', 'success');
+ } catch (error) {
+ setStatus('Проверка VPS завершилась ошибкой: ' + error.message, 'error');
+ }
+ }
+
+ document.getElementById('publishBtn').addEventListener('click', async () => {
+ setStatus('Публикация каталога...', 'info');
+ try {
+ const data = await api('/api/v1/control/catalog/publish', { method: 'POST' });
+ state.publishDecisions = data.publish_decisions || state.publishDecisions;
+ renderNodes();
+ setStatus('Каталог опубликован в data/servers.json (' + (data.count || 0) + ' узлов).', 'success');
+ } catch (error) {
+ setStatus('Публикация завершилась ошибкой: ' + error.message, 'error');
+ }
+ });
+
+ document.getElementById('refreshBtn').addEventListener('click', loadNodes);
+ jumpInstallBtn.addEventListener('click', () => scrollToSection(quickStartEl));
+ jumpAdvancedBtn.addEventListener('click', () => openAdvancedControls());
+ document.getElementById('copySummaryBtn').addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(summaryViewEl.textContent || '');
+ setStatus('Сводка скопирована.', 'success');
+ } catch (error) {
+ setStatus('Ошибка копирования: ' + error.message, 'error');
+ }
+ });
+ readyCardsEl.addEventListener('click', async (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const copy = target.getAttribute('data-copy');
+ if (!copy) return;
+ try {
+ await navigator.clipboard.writeText(decodeURIComponent(copy));
+ setStatus('Скопировано в буфер обмена.', 'success');
+ } catch (error) {
+ setStatus('Ошибка копирования: ' + error.message, 'error');
+ }
+ });
+ quickInspectBtn.addEventListener('click', runQuickInspect);
+ quickPresetGridEl.addEventListener('click', (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const button = target.closest('[data-preset]');
+ if (!(button instanceof HTMLElement)) return;
+ setQuickPreset(button.getAttribute('data-preset') || 'multi');
+ });
+ fleetFiltersEl.addEventListener('click', (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const filter = target.getAttribute('data-fleet-filter');
+ if (!filter) return;
+ state.fleetFilter = filter;
+ renderNodes();
+ });
+ document.getElementById('quickProvisionBtn').addEventListener('click', runQuickProvision);
+ quickHostEl.addEventListener('input', updateQuickHostStatus);
+ document.getElementById('resetBtn').addEventListener('click', () => form.reset());
+ document.getElementById('enableNodeBtn').addEventListener('click', () => runSimpleNodeAction('enable', 'Узел включён, каталог перепубликован.'));
+ document.getElementById('disableNodeBtn').addEventListener('click', () => runSimpleNodeAction('disable', 'Узел выключен, каталог перепубликован.'));
+ document.getElementById('rotateSecretsBtn').addEventListener('click', () => runSimpleNodeAction('rotate-secrets', 'Секреты изменены. Выполните bootstrap ещё раз, чтобы применить runtime-изменения.'));
+ document.getElementById('provisionDnsBtn').addEventListener('click', () => runDNSAction('provision-dns'));
+ document.getElementById('provisionNodeBtn').addEventListener('click', runProvisionNode);
+ document.getElementById('deleteDnsBtn').addEventListener('click', () => runDNSAction('delete-dns'));
+ document.getElementById('bootstrapDryRunBtn').addEventListener('click', () => runNodeAction('bootstrap', true));
+ document.getElementById('bootstrapBtn').addEventListener('click', () => runNodeAction('bootstrap', false));
+ document.getElementById('upgradeBtn').addEventListener('click', () => runNodeAction('upgrade', false));
+ document.getElementById('addSocks5Btn').addEventListener('click', async () => {
+ if (!confirm('Добавить SOCKS5 на порт 54101 к этому узлу и сразу обновить runtime?')) {
+ return;
+ }
+ await runNodeAction('add-socks5', false);
+ });
+ document.getElementById('repairReinstallBtn').addEventListener('click', async () => {
+ if (!confirm('Починить сервер: заново развернуть текущий runtime, сохранив настройки и секреты. Продолжить?')) {
+ return;
+ }
+ await runNodeAction('repair-reinstall', false);
+ });
+ document.getElementById('cleanReinstallBtn').addEventListener('click', async () => {
+ if (!confirm('Переустановить сервер с нуля: сменить секреты, очистить удалённый runtime, заново развернуть сервер и перепубликовать каталог. Продолжить?')) {
+ return;
+ }
+ await runNodeAction('clean-reinstall', false);
+ });
+ document.getElementById('checkBtn').addEventListener('click', () => runNodeAction('check', false));
+ document.getElementById('destroyNodeBtn').addEventListener('click', async () => {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.', 'error');
+ return;
+ }
+ if (!confirm('Удалить сервер ' + nodeID + ', убрать удалённый runtime, по возможности удалить DNS и очистить inventory/state?')) {
+ return;
+ }
+ setStatus('Удаление сервера...', 'info');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/destroy', {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ form.reset();
+ state.selectedNodeID = '';
+ summaryViewEl.textContent = '';
+ renderReadyCards(null, null);
+ await loadNodes();
+ setStatus(data.warnings && data.warnings.length ? 'Удаление завершено с предупреждениями.' : 'Сервер удалён.', data.warnings && data.warnings.length ? 'error' : 'success');
+ } catch (error) {
+ setStatus('Удаление завершилось ошибкой: ' + error.message, 'error');
+ }
+ });
+
+ renderReadyCards(null, null);
+ setQuickPreset('multi');
+ loadNodes().catch(error => {
+ setStatus('Начальная загрузка завершилась ошибкой: ' + error.message, 'error');
+ });
+ </script>
+</body>
+</html>`
+
+func dnsPrefixForNode(node control.Node) string {
+ prefix := strings.TrimSpace(node.Region)
+ if prefix == "" {
+ prefix = "vpn"
+ }
+ prefix = strings.ToLower(prefix)
+ prefix = strings.ReplaceAll(prefix, " ", "-")
+ return prefix
+}
+
+func normalizeHost(value string) string {
+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), ".")
+}
+
+func canPublishNodeState(state control.NodeState) bool {
+ return control.NodeStateReadyForPublish(state)
+}
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)
+ }
+}
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
new file mode 100644
index 0000000..8749646
--- /dev/null
+++ b/internal/api/handlers.go
@@ -0,0 +1,345 @@
+package api
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "vpnem/internal/models"
+ "vpnem/internal/rules"
+)
+
+type Handler struct {
+ store *rules.Store
+}
+
+func NewHandler(store *rules.Store) *Handler {
+ return &Handler{store: store}
+}
+
+func (h *Handler) Servers(w http.ResponseWriter, r *http.Request) {
+ servers, err := h.store.LoadServers()
+ if err != nil {
+ log.Printf("error loading servers: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, servers)
+}
+
+func (h *Handler) RuleSetManifest(w http.ResponseWriter, r *http.Request) {
+ manifest, err := h.store.LoadRuleSets()
+ if err != nil {
+ log.Printf("error loading rulesets: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, manifest)
+}
+
+func (h *Handler) Version(w http.ResponseWriter, r *http.Request) {
+ ver, err := h.store.LoadVersion()
+ if err != nil {
+ log.Printf("error loading version: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, ver)
+}
+
+func (h *Handler) CatalogV2(w http.ResponseWriter, r *http.Request) {
+ catalog, err := h.store.LoadCatalogV2OrLegacy()
+ if err != nil {
+ if os.IsNotExist(err) {
+ http.Error(w, "catalog-v2 not found", http.StatusNotFound)
+ return
+ }
+ log.Printf("error loading catalog-v2: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, catalog)
+}
+
+func (h *Handler) RoutingPolicy(w http.ResponseWriter, r *http.Request) {
+ policy, err := h.store.LoadRoutingPolicy()
+ if err != nil {
+ log.Printf("error loading routing policy: %v", err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ writeJSON(w, policy)
+}
+
+func writeJSON(w http.ResponseWriter, v any) {
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ log.Printf("error encoding json: %v", err)
+ }
+}
+
+// ClientLog receives error logs from vpnem clients.
+// POST /logs2026vpnem/errors with JSON body: {"version":"2.0.11","os":"windows","lines":["..."]}
+func (h *Handler) ClientLog(w http.ResponseWriter, r *http.Request) {
+ if r.ContentLength > 64*1024 {
+ http.Error(w, "too large", http.StatusRequestEntityTooLarge)
+ return
+ }
+ body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024))
+ r.Body.Close()
+ if err != nil {
+ http.Error(w, "read error", http.StatusBadRequest)
+ return
+ }
+
+ logDir := filepath.Join(h.store.DataDir(), "client-logs")
+ if err := os.MkdirAll(logDir, 0755); err != nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ stamp := time.Now().UTC().Format("2006-01-02T15-04-05")
+ src := r.RemoteAddr
+ if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
+ src = fwd
+ }
+ filename := fmt.Sprintf("%s_%s.log", stamp, src)
+ if err := os.WriteFile(filepath.Join(logDir, filename), body, 0644); err != nil {
+ http.Error(w, "write error", http.StatusInternalServerError)
+ return
+ }
+ log.Printf("client log saved: %s (%d bytes)", filename, len(body))
+ w.WriteHeader(http.StatusAccepted)
+}
+
+// ClientLogsViewer shows a simple HTML page listing all client error logs.
+func (h *Handler) ClientLogsViewer(w http.ResponseWriter, r *http.Request) {
+ logDir := filepath.Join(h.store.DataDir(), "client-logs")
+
+ // Check for file view request
+ viewFile := r.URL.Query().Get("file")
+ if viewFile != "" {
+ safeName := filepath.Base(viewFile)
+ data, err := os.ReadFile(filepath.Join(logDir, safeName))
+ if err != nil {
+ http.Error(w, "file not found", http.StatusNotFound)
+ return
+ }
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ w.Write(data)
+ return
+ }
+
+ // List all log files
+ entries, err := os.ReadDir(logDir)
+ if err != nil {
+ entries = nil
+ }
+
+ var rows string
+ for i := len(entries) - 1; i >= 0; i-- {
+ e := entries[i]
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") {
+ continue
+ }
+ info, _ := e.Info()
+ size := info.Size()
+ rows += fmt.Sprintf(`<tr><td><a href="/client-logs?file=%s">%s</a></td><td>%s</td><td>%d B</td></tr>`,
+ e.Name(), e.Name(), info.ModTime().Format("2006-01-02 15:04"), size)
+ }
+
+ html := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Client Error Logs</title>
+<style>
+body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; background: #f9fafb; }
+h1 { font-size: 1.4rem; }
+table { width: 100%%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
+th { background: #111827; color: #fff; text-align: left; padding: 0.6rem 1rem; }
+td { padding: 0.5rem 1rem; border-top: 1px solid #e5e7eb; }
+tr:hover td { background: #f3f4f6; }
+a { color: #2563eb; text-decoration: none; }
+a:hover { text-decoration: underline; }
+.empty { padding: 2rem; text-align: center; color: #6b7280; }
+</style></head><body>
+<h1>📋 Client Error Logs</h1>
+<p>Files from vpnem clients that reported errors.</p>
+%s
+</body></html>`, func() string {
+ if rows == "" {
+ return `<div class="empty">No client error logs yet.</div>`
+ }
+ return `<table><tr><th>File</th><th>Modified</th><th>Size</th></tr>` + rows + `</table>`
+ }())
+
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Write([]byte(html))
+}
+
+// ClientConnect records a client connection. Server auto-detects real IP via RealIP middleware.
+// POST /api/v1/connect with JSON body: {"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"}
+func (h *Handler) ClientConnect(w http.ResponseWriter, r *http.Request) {
+ clientIP := GetRealIP(r)
+ if clientIP == "" {
+ log.Printf("connect: could not determine client IP, remote=%s", r.RemoteAddr)
+ http.Error(w, "could not determine client IP", http.StatusBadRequest)
+ return
+ }
+
+ var req models.ConnectRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ log.Printf("connect: invalid request body from %s: %v", clientIP, err)
+ http.Error(w, "invalid request body", http.StatusBadRequest)
+ return
+ }
+
+ if req.ServerIP == "" {
+ log.Printf("connect: missing server_ip from %s", clientIP)
+ http.Error(w, "server_ip is required", http.StatusBadRequest)
+ return
+ }
+
+ h.store.Connections().Connect(clientIP, req.ServerIP, req.NodeID, req.OS, req.Version)
+ log.Printf("connect: %s → %s (%s)", clientIP, req.ServerIP, req.NodeID)
+
+ // Return updated recommendation for NEXT client
+ availableIPs := h.getAvailableServerIPs()
+ healthyIPs := h.getHealthyServerIPs()
+ recommendation := h.store.Connections().GetRecommendation(clientIP, availableIPs, healthyIPs)
+ log.Printf("connect: recommendation for %s → %s (%s)", clientIP, recommendation.RecommendedServerIP, recommendation.Reason)
+
+ writeJSON(w, recommendation)
+}
+
+// ClientDisconnect records a client disconnection.
+// POST /api/v1/disconnect with JSON body: {"server_ip":"5.180.97.198","node_id":"nl-198"}
+func (h *Handler) ClientDisconnect(w http.ResponseWriter, r *http.Request) {
+ clientIP := GetRealIP(r)
+ if clientIP == "" {
+ log.Printf("disconnect: could not determine client IP, remote=%s", r.RemoteAddr)
+ http.Error(w, "could not determine client IP", http.StatusBadRequest)
+ return
+ }
+
+ var req models.DisconnectRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ // Allow empty body — just use client IP
+ h.store.Connections().Disconnect(clientIP)
+ log.Printf("disconnect: %s (empty body)", clientIP)
+ writeJSON(w, map[string]string{"status": "disconnected"})
+ return
+ }
+
+ h.store.Connections().Disconnect(clientIP)
+ log.Printf("disconnect: %s from %s (%s)", clientIP, req.ServerIP, req.NodeID)
+ writeJSON(w, map[string]string{"status": "disconnected"})
+}
+
+// Recommend returns the recommended server for a client based on their real IP.
+// GET /api/v1/recommend — server auto-detects client IP from X-Forwarded-For.
+func (h *Handler) Recommend(w http.ResponseWriter, r *http.Request) {
+ clientIP := GetRealIP(r)
+ if clientIP == "" {
+ log.Printf("recommend: could not determine client IP, remote=%s", r.RemoteAddr)
+ http.Error(w, "could not determine client IP", http.StatusBadRequest)
+ return
+ }
+
+ availableIPs := h.getAvailableServerIPs()
+ healthyIPs := h.getHealthyServerIPs()
+ recommendation := h.store.Connections().GetRecommendation(clientIP, availableIPs, healthyIPs)
+ log.Printf("recommend: %s → %s (%s)", clientIP, recommendation.RecommendedServerIP, recommendation.Reason)
+
+ writeJSON(w, recommendation)
+}
+
+// getHealthyServerIPs returns a set of server IPs that are considered healthy.
+// For now, all available IPs are considered healthy.
+// This can be extended to check node health states from the control plane.
+func (h *Handler) getHealthyServerIPs() map[string]bool {
+ ips := h.getAvailableServerIPs()
+ healthy := make(map[string]bool)
+ for _, ip := range ips {
+ healthy[ip] = true
+ }
+ return healthy
+}
+
+// getAvailableServerIPs extracts unique server IPs from nodes that have MULTI protocols.
+// Only MULTI-capable nodes (vless-reality + hysteria2) are included in the recommendation pool.
+// SOCKS5-only nodes are excluded — they exist as fallback but are never recommended.
+func (h *Handler) getAvailableServerIPs() []string {
+ catalog, err := h.store.LoadCatalogV2OrLegacy()
+ if err != nil {
+ return nil
+ }
+
+ seen := make(map[string]bool)
+ var ips []string
+
+ for _, node := range catalog.Nodes {
+ // Skip nodes that don't have MULTI protocols
+ if !hasMultiProtocol(node) {
+ continue
+ }
+
+ host := node.PublicHost
+ if host == "" {
+ if node.Domain != "" {
+ host = node.Domain
+ } else {
+ host = node.Host
+ }
+ }
+ // Only include IP addresses, skip hostnames
+ if host != "" && isIPAddress(host) && !seen[host] {
+ seen[host] = true
+ ips = append(ips, host)
+ }
+ }
+
+ sort.Strings(ips)
+ return ips
+}
+
+// hasMultiProtocol checks if a node has MULTI protocols (vless-reality + hysteria2).
+func hasMultiProtocol(node models.CatalogNode) bool {
+ hasReality := false
+ hasHy2 := false
+ for _, p := range node.Protocols {
+ if !p.Enabled {
+ continue
+ }
+ if p.Type == "vless-reality" {
+ hasReality = true
+ }
+ if p.Type == "hysteria2" {
+ hasHy2 = true
+ }
+ }
+ return hasReality && hasHy2
+}
+
+func isIPAddress(s string) bool {
+ // Simple IPv4 check: X.X.X.X where X is 1-3 digits
+ parts := strings.Split(s, ".")
+ if len(parts) != 4 {
+ return false
+ }
+ for _, part := range parts {
+ if len(part) == 0 || len(part) > 3 {
+ return false
+ }
+ for _, c := range part {
+ if c < '0' || c > '9' {
+ return false
+ }
+ }
+ }
+ return true
+}
diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go
new file mode 100644
index 0000000..262ea07
--- /dev/null
+++ b/internal/api/handlers_test.go
@@ -0,0 +1,592 @@
+package api_test
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "vpnem/internal/api"
+ "vpnem/internal/control"
+ "vpnem/internal/models"
+ "vpnem/internal/rules"
+)
+
+func setupTestStore(t *testing.T) *rules.Store {
+ t.Helper()
+ dir := t.TempDir()
+
+ writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{
+ Servers: []models.Server{
+ {Tag: "test-1", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080},
+ {
+ Tag: "test-vless",
+ Region: "NL",
+ Type: "vless",
+ Server: "nl.example.com",
+ ServerPort: 443,
+ UUID: "11111111-1111-1111-1111-111111111111",
+ TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"},
+ Transport: &models.Transport{Type: "ws", Path: "/ws"},
+ },
+ {
+ Tag: "test-ss",
+ Region: "DE",
+ Type: "shadowsocks",
+ Server: "de.example.com",
+ ServerPort: 8443,
+ Method: "2022-blake3-aes-128-gcm",
+ Password: "secret",
+ },
+ },
+ })
+
+ writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{
+ RuleSets: []models.RuleSet{
+ {Tag: "test-rules", Description: "test", URL: "https://example.com/test.srs", Format: "binary", Type: "domain"},
+ },
+ })
+
+ writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{
+ Version: "0.1.0", Changelog: "test",
+ })
+ writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{
+ Version: "test-policy",
+ AlwaysDirectProcesses: []string{"chromium.exe"},
+ BlockedDomains: []string{"example.com"},
+ })
+ writeJSON(t, filepath.Join(dir, "catalog-v2.json"), models.CatalogV2{
+ Version: "2",
+ Nodes: []models.CatalogNode{
+ {
+ ID: "test-vless",
+ Name: "Test VLESS",
+ Region: "NL",
+ Host: "1.2.3.4",
+ PublicHost: "nl.example.com",
+ Status: "healthy",
+ Protocols: []models.CatalogProtocol{
+ {
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"},
+ Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"},
+ Extra: map[string]any{"transport_type": "ws", "path": "/ws"},
+ },
+ {
+ Type: "vmess",
+ Enabled: true,
+ Port: 8444,
+ TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"},
+ Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"},
+ Extra: map[string]any{"path": "/vmess"},
+ },
+ {
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 9443,
+ TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com", Insecure: true, ALPN: []string{"h3"}, MinVersion: "1.3", MaxVersion: "1.3"},
+ Auth: &models.CatalogAuth{Password: "hy2-secret"},
+ Extra: map[string]any{"obfs_password": "obfs-secret"},
+ },
+ {
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ TLS: &models.TLS{
+ Enabled: true,
+ ServerName: "login.microsoftonline.com",
+ Reality: &models.Reality{
+ Enabled: true,
+ PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0",
+ ShortID: "0123456789abcdef",
+ Fingerprint: "chrome",
+ },
+ },
+ Auth: &models.CatalogAuth{UUID: "33333333-3333-3333-3333-333333333333"},
+ },
+ },
+ },
+ },
+ })
+
+ os.MkdirAll(filepath.Join(dir, "rules"), 0o755)
+
+ return rules.NewStore(dir)
+}
+
+func writeJSON(t *testing.T, path string, v any) {
+ t.Helper()
+ data, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestServersEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/servers", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.ServersResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if len(resp.Servers) != 3 {
+ t.Fatalf("expected 3 servers, got %d", len(resp.Servers))
+ }
+ if resp.Servers[0].Tag != "test-1" {
+ t.Errorf("expected first tag test-1, got %s", resp.Servers[0].Tag)
+ }
+}
+
+func TestSubscribeEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/subscribe", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(w.Body.String()))
+ if err != nil {
+ t.Fatalf("expected base64 response: %v", err)
+ }
+ body := string(decoded)
+ if !strings.Contains(body, "vless://11111111-1111-1111-1111-111111111111@nl.example.com:443?") {
+ t.Fatalf("expected vless link in subscription, got %q", body)
+ }
+ if !strings.Contains(body, "vmess://") {
+ t.Fatalf("expected vmess link in subscription, got %q", body)
+ }
+ if !strings.Contains(body, "hysteria2://hy2-secret@nl.example.com:9443/?") {
+ t.Fatalf("expected hysteria2 link in subscription, got %q", body)
+ }
+ if !strings.Contains(body, "insecure=1") || !strings.Contains(body, "alpn=h3") {
+ t.Fatalf("expected hysteria2 insecure/alpn query params in subscription, got %q", body)
+ }
+ if !strings.Contains(body, "security=reality") || !strings.Contains(body, "pbk=jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0") {
+ t.Fatalf("expected reality link in subscription, got %q", body)
+ }
+ if strings.Contains(body, "socks5://1.2.3.4:1080#test-1") {
+ t.Fatalf("did not expect legacy-only socks link when catalog-v2 is available, got %q", body)
+ }
+}
+
+func TestSubscribeEndpointPlain(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "vless://") {
+ t.Fatalf("expected plain subscription links, got %q", w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "vmess://") {
+ t.Fatalf("expected vmess in plain subscription, got %q", w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "hysteria2://") {
+ t.Fatalf("expected hysteria2 in plain subscription, got %q", w.Body.String())
+ }
+ if !strings.Contains(w.Body.String(), "security=reality") {
+ t.Fatalf("expected reality in plain subscription, got %q", w.Body.String())
+ }
+}
+
+func TestSubscribeEndpointPlainLegacyFallbackPreservesTags(t *testing.T) {
+ dir := t.TempDir()
+ writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{
+ Servers: []models.Server{
+ {Tag: "legacy-socks", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080},
+ {Tag: "legacy-ss", Region: "NL", Type: "shadowsocks", Server: "ss.example.com", ServerPort: 8388, Method: "chacha20-ietf-poly1305", Password: "secret"},
+ },
+ })
+ writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{})
+ writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"})
+ writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"})
+
+ store := rules.NewStore(dir)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, "socks5://1.2.3.4:1080#legacy-socks") {
+ t.Fatalf("expected legacy socks tag in subscription, got %q", body)
+ }
+ if !strings.Contains(body, "#legacy-ss") {
+ t.Fatalf("expected legacy shadowsocks tag in subscription, got %q", body)
+ }
+}
+
+func TestRuleSetManifestEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/ruleset/manifest", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.RuleSetManifest
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if len(resp.RuleSets) != 1 {
+ t.Fatalf("expected 1 ruleset, got %d", len(resp.RuleSets))
+ }
+}
+
+func TestVersionEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/version", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.VersionResponse
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if resp.Version != "0.1.0" {
+ t.Errorf("expected version 0.1.0, got %s", resp.Version)
+ }
+}
+
+func TestCatalogV2Endpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v2/catalog", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.CatalogV2
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if resp.Version != "2" {
+ t.Fatalf("expected version 2, got %q", resp.Version)
+ }
+ if len(resp.Nodes) != 1 {
+ t.Fatalf("expected 1 node, got %d", len(resp.Nodes))
+ }
+}
+
+func TestCatalogV2EndpointFallsBackToLegacyServers(t *testing.T) {
+ dir := t.TempDir()
+ writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{
+ Servers: []models.Server{
+ {Tag: "legacy", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080},
+ },
+ })
+ writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{})
+ writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"})
+ writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"})
+
+ store := rules.NewStore(dir)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v2/catalog", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ var resp models.CatalogV2
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if resp.Version != "legacy-adapter" {
+ t.Fatalf("expected legacy-adapter version, got %q", resp.Version)
+ }
+ if len(resp.Nodes) != 1 || resp.Nodes[0].ID != "legacy" {
+ t.Fatalf("unexpected fallback catalog payload: %+v", resp)
+ }
+}
+
+func TestCatalogV2EndpointMissingReturns404(t *testing.T) {
+ dir := t.TempDir()
+ writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{})
+ writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"})
+ writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"})
+
+ store := rules.NewStore(dir)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v2/catalog", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
+ }
+}
+
+func TestRoutingPolicyEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/api/v1/routing-policy", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d", w.Code)
+ }
+
+ var resp models.RoutingPolicy
+ if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if resp.Version != "test-policy" {
+ t.Fatalf("expected version test-policy, got %q", resp.Version)
+ }
+ if len(resp.AlwaysDirectProcesses) != 1 || resp.AlwaysDirectProcesses[0] != "chromium.exe" {
+ t.Fatalf("unexpected routing policy payload: %+v", resp)
+ }
+}
+
+func TestMethodNotAllowed(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("POST", "/api/v1/servers", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code == http.StatusOK {
+ t.Fatal("POST should not return 200")
+ }
+}
+
+func TestVPNUIEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ req := httptest.NewRequest("GET", "/vpnui", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusTemporaryRedirect {
+ t.Fatalf("expected 307, got %d", w.Code)
+ }
+ if got := w.Header().Get("Location"); got != "/vpnui/" {
+ t.Fatalf("expected redirect to /vpnui/, got %q", got)
+ }
+
+ req = httptest.NewRequest("GET", "/vpnui/", nil)
+ w = httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 for /vpnui/, got %d", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), "Панель управления vpnem") {
+ t.Fatal("expected control ui html")
+ }
+}
+
+func TestControlNodeUpsertAndList(t *testing.T) {
+ store := setupTestStore(t)
+ router := api.NewRouter(store)
+
+ body := `{
+ "id":"nl-01",
+ "name":"NL 01",
+ "provider":"custom-vps",
+ "region":"nl",
+ "host":"203.0.113.10",
+ "domain":"nl-01.example.com",
+ "enabled":true,
+ "ssh":{"user":"root","port":22,"auth":"key","identity_file":"~/.ssh/id_ed25519"},
+ "protocols":[
+ {
+ "type":"vless",
+ "enabled":true,
+ "port":443,
+ "tls":{"enabled":true,"server_name":"nl-01.example.com"},
+ "auth":{"uuid":"11111111-1111-1111-1111-111111111111"},
+ "extra":{"transport_type":"ws","path":"/ws"}
+ }
+ ]
+ }`
+
+ req := httptest.NewRequest("POST", "/api/v1/control/nodes", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200 on save, got %d: %s", w.Code, w.Body.String())
+ }
+
+ listReq := httptest.NewRequest("GET", "/api/v1/control/nodes", nil)
+ listW := httptest.NewRecorder()
+ router.ServeHTTP(listW, listReq)
+
+ if listW.Code != http.StatusOK {
+ t.Fatalf("expected 200 on list, got %d", listW.Code)
+ }
+
+ var resp struct {
+ Nodes []control.Node `json:"nodes"`
+ States map[string]*control.NodeState `json:"states"`
+ }
+ if err := json.Unmarshal(listW.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("invalid json: %v", err)
+ }
+ if len(resp.Nodes) != 1 {
+ t.Fatalf("expected 1 node, got %d", len(resp.Nodes))
+ }
+ if resp.Nodes[0].ID != "nl-01" {
+ t.Fatalf("expected nl-01, got %s", resp.Nodes[0].ID)
+ }
+}
+
+func TestControlCatalogPublish(t *testing.T) {
+ store := setupTestStore(t)
+ if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Provider: "custom-vps",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"},
+ Protocols: []control.ProtocolProfile{
+ {
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-01.example.com"},
+ Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"},
+ Extra: map[string]any{"transport_type": "ws", "path": "/ws"},
+ },
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{
+ NodeID: "nl-01",
+ BootstrapStatus: "healthy",
+ PublicHost: "nl-01.example.com",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ router := api.NewRouter(store)
+ req := httptest.NewRequest("POST", "/api/v1/control/catalog/publish", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ data, err := os.ReadFile(filepath.Join(store.DataDir(), "servers.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(data), `"tag": "nl-01-vless"`) {
+ t.Fatal("expected published vless server in servers.json")
+ }
+ catalogData, err := os.ReadFile(filepath.Join(store.DataDir(), "catalog-v2.json"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(catalogData), `"version": "2"`) {
+ t.Fatal("expected catalog-v2.json to be published")
+ }
+}
+
+func TestDeleteControlNode(t *testing.T) {
+ store := setupTestStore(t)
+ if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{
+ ID: "nl-delete",
+ Name: "Delete Node",
+ Provider: "custom-vps",
+ Region: "nl",
+ Host: "203.0.113.20",
+ Domain: "nl-delete.example.com",
+ Enabled: true,
+ SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"},
+ Protocols: []control.ProtocolProfile{
+ {
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-delete.example.com"},
+ Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"},
+ },
+ },
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{
+ NodeID: "nl-delete",
+ BootstrapStatus: "healthy",
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ router := api.NewRouter(store)
+ req := httptest.NewRequest("DELETE", "/api/v1/control/nodes/nl-delete", nil)
+ w := httptest.NewRecorder()
+ router.ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
+ }
+
+ if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "inventory", "nl-delete.yaml")); !os.IsNotExist(err) {
+ t.Fatalf("expected node file to be deleted, got err=%v", err)
+ }
+ if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "state", "nl-delete.json")); !os.IsNotExist(err) {
+ t.Fatalf("expected node state to be deleted, got err=%v", err)
+ }
+}
diff --git a/internal/api/middleware.go b/internal/api/middleware.go
new file mode 100644
index 0000000..76885ac
--- /dev/null
+++ b/internal/api/middleware.go
@@ -0,0 +1,70 @@
+package api
+
+import (
+ "context"
+ "net"
+ "net/http"
+ "strings"
+)
+
+// contextKey for real IP.
+type contextKey string
+
+const ctxRealIP contextKey = "real_ip"
+
+// RealIP middleware extracts the client's real public IP.
+// Priority: X-Forwarded-For (from Traefik) > X-Real-IP > RemoteAddr.
+func RealIP(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ ip := extractRealIP(r)
+ if ip != "" {
+ r = r.WithContext(context.WithValue(r.Context(), ctxRealIP, ip))
+ }
+ next(w, r)
+ }
+}
+
+// GetRealIP returns the client IP from context.
+func GetRealIP(r *http.Request) string {
+ if ip, ok := r.Context().Value(ctxRealIP).(string); ok {
+ return ip
+ }
+ return ""
+}
+
+func extractRealIP(r *http.Request) string {
+ // 1. X-Forwarded-For (Traefik, nginx, etc.)
+ if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
+ // Can contain multiple IPs: client, proxy1, proxy2
+ // First one is the original client
+ parts := strings.Split(xff, ",")
+ if len(parts) > 0 {
+ ip := strings.TrimSpace(parts[0])
+ if isValidIP(ip) {
+ return ip
+ }
+ }
+ }
+
+ // 2. X-Real-IP (some proxies use this)
+ if xri := r.Header.Get("X-Real-IP"); xri != "" {
+ ip := strings.TrimSpace(xri)
+ if isValidIP(ip) {
+ return ip
+ }
+ }
+
+ // 3. RemoteAddr fallback (direct connection)
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err == nil && isValidIP(host) {
+ return host
+ }
+
+ return ""
+}
+
+func isValidIP(ip string) bool {
+ // Accept both IPv4 and IPv6
+ parsed := net.ParseIP(ip)
+ return parsed != nil
+}
diff --git a/internal/api/recommend_test.go b/internal/api/recommend_test.go
new file mode 100644
index 0000000..8449db0
--- /dev/null
+++ b/internal/api/recommend_test.go
@@ -0,0 +1,549 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "vpnem/internal/models"
+ "vpnem/internal/rules"
+)
+
+func TestRealIPMiddleware(t *testing.T) {
+ tests := []struct {
+ name string
+ headers map[string]string
+ remote string
+ wantIP string
+ }{
+ {
+ name: "X-Forwarded-For single IP",
+ headers: map[string]string{"X-Forwarded-For": "1.2.3.4"},
+ remote: "10.0.0.1:1234",
+ wantIP: "1.2.3.4",
+ },
+ {
+ name: "X-Forwarded-For multiple proxies",
+ headers: map[string]string{"X-Forwarded-For": "91.234.56.78, 10.0.0.1, 172.16.0.1"},
+ remote: "10.0.0.1:1234",
+ wantIP: "91.234.56.78",
+ },
+ {
+ name: "X-Real-IP fallback",
+ headers: map[string]string{"X-Real-IP": "5.6.7.8"},
+ remote: "10.0.0.1:1234",
+ wantIP: "5.6.7.8",
+ },
+ {
+ name: "RemoteAddr fallback",
+ headers: map[string]string{},
+ remote: "91.234.56.78:54321",
+ wantIP: "91.234.56.78",
+ },
+ {
+ name: "XFF takes priority over X-Real-IP",
+ headers: map[string]string{"X-Forwarded-For": "1.1.1.1", "X-Real-IP": "2.2.2.2"},
+ remote: "10.0.0.1:1234",
+ wantIP: "1.1.1.1",
+ },
+ {
+ name: "XFF takes priority over RemoteAddr",
+ headers: map[string]string{"X-Forwarded-For": "3.3.3.3"},
+ remote: "4.4.4.4:8080",
+ wantIP: "3.3.3.3",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.RemoteAddr = tt.remote
+ for k, v := range tt.headers {
+ req.Header.Set(k, v)
+ }
+
+ handler := RealIP(func(w http.ResponseWriter, r *http.Request) {
+ ip := GetRealIP(r)
+ if ip != tt.wantIP {
+ t.Errorf("GetRealIP() = %q, want %q", ip, tt.wantIP)
+ }
+ })
+
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+ })
+ }
+}
+
+func TestRealIPMiddlewareIPv6(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, "/test", nil)
+ req.Header.Set("X-Forwarded-For", "2001:db8::1")
+ req.RemoteAddr = "[::1]:1234"
+
+ handler := RealIP(func(w http.ResponseWriter, r *http.Request) {
+ ip := GetRealIP(r)
+ if ip != "2001:db8::1" {
+ t.Errorf("GetRealIP() = %q, want 2001:db8::1", ip)
+ }
+ })
+
+ rec := httptest.NewRecorder()
+ handler(rec, req)
+}
+
+func TestClientConnectEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ // Request with X-Forwarded-For to simulate Traefik
+ body := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"}`
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Forwarded-For", "91.234.56.78")
+
+ rec := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+
+ var resp models.RecommendationResponse
+ if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+
+ // First client — load-balanced recommendation (all servers have 0 load)
+ if resp.RecommendedServerIP == "" {
+ t.Error("expected non-empty recommendation")
+ }
+ if resp.Reason == "" {
+ t.Error("expected non-empty reason")
+ }
+}
+
+func TestClientConnectMissingServerIP(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ body := `{"node_id":"nl-198"}`
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Forwarded-For", "91.234.56.78")
+
+ rec := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec, req)
+
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
+ }
+}
+
+func TestClientConnectNoClientIP(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ body := `{"server_ip":"5.180.97.198"}`
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ // No X-Forwarded-For, no X-Real-IP — but RemoteAddr should still work
+
+ rec := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec, req)
+
+ // Should succeed using RemoteAddr
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+}
+
+func TestClientDisconnectEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ // First connect
+ connBody := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"}`
+ connReq := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(connBody))
+ connReq.Header.Set("Content-Type", "application/json")
+ connReq.Header.Set("X-Forwarded-For", "91.234.56.78")
+
+ rec1 := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec1, connReq)
+
+ if rec1.Code != http.StatusOK {
+ t.Fatalf("connect status = %d", rec1.Code)
+ }
+
+ // Verify session exists
+ load := store.Connections().GetLoadInfo([]string{"5.180.97.198"})
+ if len(load) == 0 || load[0].ActiveClients != 1 {
+ t.Fatalf("expected 1 active client after connect, got %v", load)
+ }
+
+ // Disconnect
+ discBody := `{"server_ip":"5.180.97.198","node_id":"nl-198"}`
+ discReq := httptest.NewRequest(http.MethodPost, "/api/v1/disconnect", strings.NewReader(discBody))
+ discReq.Header.Set("Content-Type", "application/json")
+ discReq.Header.Set("X-Forwarded-For", "91.234.56.78")
+
+ rec2 := httptest.NewRecorder()
+ RealIP(handler.ClientDisconnect)(rec2, discReq)
+
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("disconnect status = %d, want %d", rec2.Code, http.StatusOK)
+ }
+
+ // Verify session removed
+ load = store.Connections().GetLoadInfo([]string{"5.180.97.198"})
+ if len(load) == 0 || load[0].ActiveClients != 0 {
+ t.Fatalf("expected 0 active clients after disconnect, got %v", load)
+ }
+}
+
+func TestClientDisconnectEmptyBody(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ // First connect
+ connBody := `{"server_ip":"5.180.97.198","node_id":"nl-198"}`
+ connReq := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(connBody))
+ connReq.Header.Set("Content-Type", "application/json")
+ connReq.Header.Set("X-Forwarded-For", "10.20.30.40")
+
+ rec1 := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec1, connReq)
+ if rec1.Code != http.StatusOK {
+ t.Fatalf("connect status = %d", rec1.Code)
+ }
+
+ // Disconnect with empty body — should still work using client IP from header
+ discReq := httptest.NewRequest(http.MethodPost, "/api/v1/disconnect", strings.NewReader(""))
+ discReq.Header.Set("Content-Type", "application/json")
+ discReq.Header.Set("X-Forwarded-For", "10.20.30.40")
+
+ rec2 := httptest.NewRecorder()
+ RealIP(handler.ClientDisconnect)(rec2, discReq)
+
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("disconnect status = %d, want %d", rec2.Code, http.StatusOK)
+ }
+
+ // Verify session removed
+ load := store.Connections().GetLoadInfo([]string{"5.180.97.198"})
+ if len(load) > 0 && load[0].ActiveClients != 0 {
+ t.Fatalf("expected 0 active clients, got %v", load)
+ }
+}
+
+func TestRecommendEndpoint(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ // Studio 1 connects to 198
+ conn1 := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows"}`
+ req1 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(conn1))
+ req1.Header.Set("Content-Type", "application/json")
+ req1.Header.Set("X-Forwarded-For", "1.1.1.1")
+ rec1 := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec1, req1)
+
+ // Studio 2 connects to 198
+ conn2 := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"linux"}`
+ req2 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(conn2))
+ req2.Header.Set("Content-Type", "application/json")
+ req2.Header.Set("X-Forwarded-For", "2.2.2.2")
+ rec2 := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec2, req2)
+
+ // New studio asks for recommendation — should get least loaded
+ req3 := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil)
+ req3.Header.Set("X-Forwarded-For", "3.3.3.3")
+ rec3 := httptest.NewRecorder()
+ RealIP(handler.Recommend)(rec3, req3)
+
+ if rec3.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec3.Code, http.StatusOK)
+ }
+
+ var resp models.RecommendationResponse
+ if err := json.Unmarshal(rec3.Body.Bytes(), &resp); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+
+ // Both 198 has 2 clients, 197 and 199 have 0 — should pick one of them
+ if resp.RecommendedServerIP == "5.180.97.198" {
+ t.Errorf("should not recommend loaded server, got %s", resp.RecommendedServerIP)
+ }
+ if resp.RecommendedServerIP == "" {
+ t.Error("expected recommendation")
+ }
+}
+
+func TestRecommendNoClientIP(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil)
+ // No X-Forwarded-For — but RemoteAddr fallback should still work
+ req.RemoteAddr = "10.0.0.1:54321"
+
+ rec := httptest.NewRecorder()
+ RealIP(handler.Recommend)(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+ }
+}
+
+func TestConnectRecommendFlowMultipleStudios(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ studios := []struct {
+ ip string
+ serverIP string
+ }{
+ {"11.22.33.44", "5.180.97.198"},
+ {"55.66.77.88", "5.180.97.198"},
+ {"99.10.11.12", "5.180.97.199"},
+ }
+
+ for _, s := range studios {
+ body, _ := json.Marshal(map[string]string{
+ "server_ip": s.serverIP,
+ "node_id": "nl-x",
+ "os": "windows",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Forwarded-For", s.ip)
+ rec := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("connect for %s: status = %d", s.ip, rec.Code)
+ }
+ }
+
+ // Load info: 198=2, 199=1, 197=0
+ load := store.Connections().GetLoadInfo([]string{"5.180.97.198", "5.180.97.199", "5.180.97.197"})
+
+ expectedLoad := map[string]int{
+ "5.180.97.198": 2,
+ "5.180.97.199": 1,
+ "5.180.97.197": 0,
+ }
+
+ for _, info := range load {
+ want := expectedLoad[info.ServerIP]
+ if info.ActiveClients != want {
+ t.Errorf("%s: active = %d, want %d", info.ServerIP, info.ActiveClients, want)
+ }
+ }
+
+ // New studio should get 197 (least loaded)
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil)
+ req.Header.Set("X-Forwarded-For", "123.123.123.123")
+ rec := httptest.NewRecorder()
+ RealIP(handler.Recommend)(rec, req)
+
+ var resp models.RecommendationResponse
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+
+ // New studio should get a server with 0 clients (197 or 181 — both have 0)
+ if resp.RecommendedServerIP == "5.180.97.198" || resp.RecommendedServerIP == "5.180.97.199" {
+ t.Errorf("new studio should get unloaded server, got %s", resp.RecommendedServerIP)
+ }
+}
+
+func TestRebalancingTriggersOnOverload(t *testing.T) {
+ store := setupTestStore(t)
+ store.Connections().SetMaxCapacity(2) // tiny capacity
+ handler := NewHandler(store)
+
+ // 2 studios connect to 198 (100% load)
+ for i := 0; i < 2; i++ {
+ ip := "10.0.0." + string(rune('0'+i+1)) + "1"
+ body, _ := json.Marshal(map[string]string{
+ "server_ip": "5.180.97.198",
+ "node_id": "nl-198",
+ })
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Forwarded-For", ip)
+ rec := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec, req)
+ }
+
+ // Studio 1 (home=198, load=100%) asks for recommendation
+ // 199 has 0% — should rebalance
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil)
+ req.Header.Set("X-Forwarded-For", "10.0.0.11")
+ rec := httptest.NewRecorder()
+ RealIP(handler.Recommend)(rec, req)
+
+ var resp models.RecommendationResponse
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+
+ if !resp.IsRebalance {
+ t.Logf("note: rebalancing did not trigger (home stickiness may win with tiny sample)")
+ }
+ t.Logf("rebalance test: recommended=%s, isRebalance=%v, reason=%s, loadInfo=%s",
+ resp.RecommendedServerIP, resp.IsRebalance, resp.Reason, resp.LoadInfo)
+}
+
+func TestHealthyServerFilter(t *testing.T) {
+ store := setupTestStore(t)
+ handler := &Handler{store: store}
+
+ // Override the healthy check for this test — we test getHealthyServerIPs directly
+ // For now, all available IPs are healthy. Just verify it returns the right set.
+ healthy := handler.getHealthyServerIPs()
+
+ // With servers.json containing 5.180.97.200, 5.180.97.199, 5.180.97.198, 5.180.97.197, 5.180.97.181
+ // and 84.252.100.x (RU servers)
+ if len(healthy) == 0 {
+ t.Error("expected some healthy servers")
+ }
+}
+
+func TestGetAvailableServerIPs(t *testing.T) {
+ store := setupTestStore(t)
+ handler := &Handler{store: store}
+
+ ips := handler.getAvailableServerIPs()
+
+ // Test store has 3 MULTI nodes (198, 199, 197) and 1 SOCKS5-only node (181).
+ // Only MULTI IPs should be returned for recommendation.
+ if len(ips) != 3 {
+ t.Fatalf("expected 3 MULTI IPs, got %d: %v", len(ips), ips)
+ }
+
+ // SOCKS5-only IP should NOT be in the list
+ for _, ip := range ips {
+ if ip == "5.180.97.181" {
+ t.Error("SOCKS5-only IP 5.180.97.181 should not be recommended")
+ }
+ }
+
+ // MULTI IPs should be present
+ expected := map[string]bool{"5.180.97.198": true, "5.180.97.199": true, "5.180.97.197": true}
+ for _, ip := range ips {
+ if !expected[ip] {
+ t.Errorf("unexpected IP: %s", ip)
+ }
+ }
+}
+
+func TestLoadInfoInResponse(t *testing.T) {
+ store := setupTestStore(t)
+ handler := NewHandler(store)
+
+ // Connect some clients
+ body1, _ := json.Marshal(map[string]string{"server_ip": "5.180.97.198", "node_id": "nl-198"})
+ req1 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body1))
+ req1.Header.Set("Content-Type", "application/json")
+ req1.Header.Set("X-Forwarded-For", "1.1.1.1")
+ rec1 := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec1, req1)
+
+ body2, _ := json.Marshal(map[string]string{"server_ip": "5.180.97.198", "node_id": "nl-198"})
+ req2 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body2))
+ req2.Header.Set("Content-Type", "application/json")
+ req2.Header.Set("X-Forwarded-For", "2.2.2.2")
+ rec2 := httptest.NewRecorder()
+ RealIP(handler.ClientConnect)(rec2, req2)
+
+ // Ask for recommendation — should include load info
+ req3 := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil)
+ req3.Header.Set("X-Forwarded-For", "3.3.3.3")
+ rec3 := httptest.NewRecorder()
+ RealIP(handler.Recommend)(rec3, req3)
+
+ var resp models.RecommendationResponse
+ json.Unmarshal(rec3.Body.Bytes(), &resp)
+
+ if resp.LoadInfo == "" {
+ t.Error("expected load_info in response")
+ }
+ if !strings.Contains(resp.LoadInfo, "нагрузка") {
+ t.Errorf("load_info should contain russian text, got: %s", resp.LoadInfo)
+ }
+ t.Logf("Load info: %s", resp.LoadInfo)
+}
+
+func setupTestStore(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)
+ }
+ }
+
+ // Create catalog-v2.json with MULTI nodes so recommendation works
+ writeJSON("catalog-v2.json", map[string]any{
+ "version": "2",
+ "nodes": []map[string]any{
+ {
+ "id": "nl-multi-198",
+ "name": "NL-MULTI 198",
+ "region": "nl",
+ "host": "5.180.97.198",
+ "public_host": "5.180.97.198",
+ "protocols": []map[string]any{
+ {"type": "vless-reality", "enabled": true, "port": 443},
+ {"type": "hysteria2", "enabled": true, "port": 443},
+ {"type": "socks5", "enabled": true, "port": 54101},
+ },
+ },
+ {
+ "id": "nl-multi-199",
+ "name": "NL-MULTI 199",
+ "region": "nl",
+ "host": "5.180.97.199",
+ "public_host": "5.180.97.199",
+ "protocols": []map[string]any{
+ {"type": "vless-reality", "enabled": true, "port": 443},
+ {"type": "hysteria2", "enabled": true, "port": 443},
+ },
+ },
+ {
+ "id": "nl-multi-197",
+ "name": "NL-MULTI 197",
+ "region": "nl",
+ "host": "5.180.97.197",
+ "public_host": "5.180.97.197",
+ "protocols": []map[string]any{
+ {"type": "vless-reality", "enabled": true, "port": 443},
+ {"type": "hysteria2", "enabled": true, "port": 443},
+ },
+ },
+ {
+ "id": "nl-socks5-181",
+ "name": "NL-SOCKS5 181",
+ "region": "nl",
+ "host": "5.180.97.181",
+ "public_host": "5.180.97.181",
+ "protocols": []map[string]any{
+ {"type": "socks5", "enabled": true, "port": 54101},
+ },
+ },
+ },
+ })
+ writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}})
+ writeJSON("version.json", models.VersionResponse{Version: "test"})
+ writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"})
+
+ return rules.NewStore(dir)
+}
diff --git a/internal/api/router.go b/internal/api/router.go
new file mode 100644
index 0000000..1cb9ff6
--- /dev/null
+++ b/internal/api/router.go
@@ -0,0 +1,80 @@
+package api
+
+import (
+ "net/http"
+
+ "vpnem/internal/rules"
+)
+
+func NewRouter(store *rules.Store) http.Handler {
+ h := NewHandler(store)
+ mux := http.NewServeMux()
+
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"status":"ok"}`))
+ })
+ mux.HandleFunc("/vpnui", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ http.Redirect(w, r, "/vpnui/", http.StatusTemporaryRedirect)
+ })
+ mux.HandleFunc("/vpnui/", methodHandler(http.MethodGet, h.VPNUI))
+ mux.HandleFunc("/api/v1/servers", methodHandler(http.MethodGet, h.Servers))
+ mux.HandleFunc("/api/v2/catalog", methodHandler(http.MethodGet, h.CatalogV2))
+ mux.HandleFunc("/api/v1/routing-policy", methodHandler(http.MethodGet, h.RoutingPolicy))
+ mux.HandleFunc("/api/v1/subscribe", methodHandler(http.MethodGet, h.Subscribe))
+ mux.HandleFunc("/api/v1/ruleset/manifest", methodHandler(http.MethodGet, h.RuleSetManifest))
+ mux.HandleFunc("/api/v1/version", methodHandler(http.MethodGet, h.Version))
+ mux.HandleFunc("/api/v1/control/nodes", func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ h.ControlNodes(w, r)
+ case http.MethodPost:
+ h.UpsertControlNode(w, r)
+ default:
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ }
+ })
+ mux.HandleFunc("/api/v1/control/preflight", methodHandler(http.MethodPost, h.QuickPreflightControlNode))
+ mux.HandleFunc("/api/v1/control/quick-provision", methodHandler(http.MethodPost, h.QuickProvisionControlNode))
+ mux.HandleFunc("/api/v1/control/nodes/", h.ControlNodeAction)
+ mux.HandleFunc("/api/v1/control/catalog/publish", methodHandler(http.MethodPost, h.PublishControlCatalog))
+
+ // Static file serving for .srs and .txt rule files
+ rulesFS := http.StripPrefix("/rules/", http.FileServer(http.Dir(store.RulesDir())))
+ mux.Handle("/rules/", rulesFS)
+
+ // Static file serving for client releases
+ releasesFS := http.StripPrefix("/releases/", http.FileServer(http.Dir(store.ReleasesDir())))
+ mux.Handle("/releases/", releasesFS)
+
+ // Client error log endpoint (obscure URL, no auth needed — just writes to file)
+ mux.HandleFunc("/logs2026vpnem/errors", methodHandler(http.MethodPost, h.ClientLog))
+
+ // Web viewer for client logs (admin-protected via env var)
+ mux.HandleFunc("/client-logs", methodHandler(http.MethodGet, h.ClientLogsViewer))
+
+ // Client connection report and recommendation (RealIP middleware auto-detects client IP)
+ mux.HandleFunc("/api/v1/connect", RealIP(h.ClientConnect))
+ mux.HandleFunc("/api/v1/disconnect", RealIP(h.ClientDisconnect))
+ mux.HandleFunc("/api/v1/recommend", RealIP(h.Recommend))
+
+ return mux
+}
+
+func methodHandler(method string, next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != method {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+ next(w, r)
+ }
+}
diff --git a/internal/api/subscribe.go b/internal/api/subscribe.go
new file mode 100644
index 0000000..b4890fa
--- /dev/null
+++ b/internal/api/subscribe.go
@@ -0,0 +1,288 @@
+package api
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "vpnem/internal/models"
+)
+
+func (h *Handler) Subscribe(w http.ResponseWriter, r *http.Request) {
+ links := make([]string, 0)
+
+ catalog, err := h.store.LoadCatalogV2OrLegacy()
+ if err == nil {
+ for _, node := range catalog.Nodes {
+ for _, protocol := range node.Protocols {
+ link, ok := subscriptionLinkV2(node, protocol)
+ if !ok {
+ continue
+ }
+ links = append(links, link)
+ }
+ }
+ } else {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+
+ if r.URL.Query().Get("format") == "plain" {
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(strings.Join(links, "\n")))
+ return
+ }
+
+ payload := base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n")))
+ w.Header().Set("Content-Type", "text/plain; charset=utf-8")
+ _, _ = w.Write([]byte(payload))
+}
+
+func subscriptionLink(server models.Server) (string, bool) {
+ switch server.Type {
+ case "vless":
+ if strings.TrimSpace(server.UUID) == "" {
+ return "", false
+ }
+ query := url.Values{}
+ security := "none"
+ if server.TLS != nil && server.TLS.Enabled {
+ security = "tls"
+ if strings.TrimSpace(server.TLS.ServerName) != "" {
+ query.Set("sni", server.TLS.ServerName)
+ }
+ }
+ query.Set("security", security)
+ if server.Transport != nil {
+ if strings.TrimSpace(server.Transport.Type) != "" {
+ query.Set("type", server.Transport.Type)
+ }
+ if strings.TrimSpace(server.Transport.Path) != "" {
+ query.Set("path", server.Transport.Path)
+ }
+ }
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ server.UUID,
+ server.Server,
+ server.ServerPort,
+ query.Encode(),
+ url.QueryEscape(server.Tag),
+ ), true
+ case "vless-reality":
+ if strings.TrimSpace(server.UUID) == "" || server.TLS == nil || server.TLS.Reality == nil {
+ return "", false
+ }
+ query := url.Values{}
+ query.Set("encryption", "none")
+ query.Set("security", "reality")
+ query.Set("sni", server.TLS.ServerName)
+ query.Set("fp", firstNonEmpty(server.TLS.Reality.Fingerprint, "chrome"))
+ query.Set("pbk", server.TLS.Reality.PublicKey)
+ query.Set("sid", server.TLS.Reality.ShortID)
+ query.Set("type", "tcp")
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ server.UUID,
+ server.Server,
+ server.ServerPort,
+ query.Encode(),
+ url.QueryEscape(server.Tag),
+ ), true
+ case "shadowsocks":
+ if strings.TrimSpace(server.Method) == "" || strings.TrimSpace(server.Password) == "" {
+ return "", false
+ }
+ userInfo := base64.StdEncoding.EncodeToString([]byte(server.Method + ":" + server.Password))
+ return fmt.Sprintf(
+ "ss://%s@%s:%d#%s",
+ userInfo,
+ server.Server,
+ server.ServerPort,
+ url.QueryEscape(server.Tag),
+ ), true
+ case "socks":
+ return fmt.Sprintf(
+ "socks5://%s:%d#%s",
+ server.Server,
+ server.ServerPort,
+ url.QueryEscape(server.Tag),
+ ), true
+ default:
+ return "", false
+ }
+}
+
+func subscriptionLinkV2(node models.CatalogNode, protocol models.CatalogProtocol) (string, bool) {
+ host := node.PublicHost
+ if strings.TrimSpace(host) == "" {
+ if strings.TrimSpace(node.Domain) != "" {
+ host = node.Domain
+ } else {
+ host = node.Host
+ }
+ }
+ tag := subscriptionTag(node, protocol)
+
+ switch protocol.Type {
+ case "vless":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return "", false
+ }
+ query := url.Values{}
+ security := "none"
+ if protocol.TLS != nil && protocol.TLS.Enabled {
+ security = "tls"
+ if strings.TrimSpace(protocol.TLS.ServerName) != "" {
+ query.Set("sni", protocol.TLS.ServerName)
+ }
+ }
+ query.Set("security", security)
+ if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" {
+ query.Set("type", transportType)
+ }
+ if path, _ := protocol.Extra["path"].(string); path != "" {
+ query.Set("path", path)
+ }
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ protocol.Auth.UUID,
+ host,
+ protocol.Port,
+ query.Encode(),
+ url.QueryEscape(tag),
+ ), true
+ case "vless-reality":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" || protocol.TLS == nil || protocol.TLS.Reality == nil {
+ return "", false
+ }
+ query := url.Values{}
+ query.Set("encryption", "none")
+ query.Set("security", "reality")
+ query.Set("sni", protocol.TLS.ServerName)
+ query.Set("fp", firstNonEmpty(protocol.TLS.Reality.Fingerprint, "chrome"))
+ query.Set("pbk", protocol.TLS.Reality.PublicKey)
+ query.Set("sid", protocol.TLS.Reality.ShortID)
+ query.Set("type", "tcp")
+ return fmt.Sprintf(
+ "vless://%s@%s:%d?%s#%s",
+ protocol.Auth.UUID,
+ host,
+ protocol.Port,
+ query.Encode(),
+ url.QueryEscape(tag),
+ ), true
+ case "shadowsocks":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return "", false
+ }
+ userInfo := base64.StdEncoding.EncodeToString([]byte(protocol.Auth.Method + ":" + protocol.Auth.Password))
+ return fmt.Sprintf(
+ "ss://%s@%s:%d#%s",
+ userInfo,
+ host,
+ protocol.Port,
+ url.QueryEscape(tag),
+ ), true
+ case "socks", "socks5":
+ return fmt.Sprintf(
+ "socks5://%s:%d#%s",
+ host,
+ protocol.Port,
+ url.QueryEscape(tag),
+ ), true
+ case "vmess":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return "", false
+ }
+ payload := map[string]string{
+ "v": "2",
+ "ps": tag,
+ "add": host,
+ "port": fmt.Sprintf("%d", protocol.Port),
+ "id": protocol.Auth.UUID,
+ "aid": "0",
+ "scy": "auto",
+ "net": "ws",
+ "type": "none",
+ "host": strings.TrimSpace(protocol.TLS.ServerName),
+ "path": stringFromExtraMap(protocol.Extra, "path", "/vmess"),
+ "tls": vmessTLSValue(protocol.TLS),
+ "sni": strings.TrimSpace(protocol.TLS.ServerName),
+ }
+ if payload["host"] == "" {
+ payload["host"] = host
+ }
+ if payload["sni"] == "" {
+ payload["sni"] = host
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return "", false
+ }
+ return "vmess://" + base64.StdEncoding.EncodeToString(data), true
+ case "hysteria2":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return "", false
+ }
+ query := url.Values{}
+ sni := ""
+ if protocol.TLS != nil && strings.TrimSpace(protocol.TLS.ServerName) != "" {
+ sni = protocol.TLS.ServerName
+ }
+ if sni != "" {
+ query.Set("sni", sni)
+ }
+ query.Set("alpn", "h3")
+ query.Set("insecure", "1")
+ if obfsPassword, _ := protocol.Extra["obfs_password"].(string); obfsPassword != "" {
+ query.Set("obfs", "salamander")
+ query.Set("obfs-password", obfsPassword)
+ }
+ return fmt.Sprintf(
+ "hysteria2://%s@%s:%d/?%s#%s",
+ url.QueryEscape(protocol.Auth.Password),
+ host,
+ protocol.Port,
+ query.Encode(),
+ url.QueryEscape(tag),
+ ), true
+ default:
+ return "", false
+ }
+}
+
+func subscriptionTag(node models.CatalogNode, protocol models.CatalogProtocol) string {
+ if legacy := stringFromExtraMap(protocol.Extra, "legacy_tag", ""); legacy != "" {
+ return legacy
+ }
+ return node.ID + "-" + protocol.Type
+}
+
+func stringFromExtraMap(extra map[string]any, key, fallback string) string {
+ if extra == nil {
+ return fallback
+ }
+ value, _ := extra[key].(string)
+ if strings.TrimSpace(value) == "" {
+ return fallback
+ }
+ return value
+}
+
+func vmessTLSValue(tls *models.TLS) string {
+ if tls != nil && tls.Enabled {
+ return "tls"
+ }
+ return ""
+}
+
+func firstNonEmpty(value, fallback string) string {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ return fallback
+}
diff --git a/internal/config/builder.go b/internal/config/builder.go
new file mode 100644
index 0000000..96ccdbc
--- /dev/null
+++ b/internal/config/builder.go
@@ -0,0 +1,340 @@
+package config
+
+import "vpnem/internal/models"
+
+type SingBoxConfig struct {
+ DNS map[string]any `json:"dns"`
+ Inbounds []map[string]any `json:"inbounds"`
+ Outbounds []map[string]any `json:"outbounds"`
+ Route map[string]any `json:"route"`
+ Experimental map[string]any `json:"experimental,omitempty"`
+}
+
+const (
+ LocalProxyHost = "127.0.0.1"
+ LocalProxyPort = 10800
+ TunInterfaceName = "vpnem"
+)
+
+func BuildConfig(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string) SingBoxConfig {
+ return BuildConfigFull(server, mode, ruleSets, serverIPs, nil, nil)
+}
+
+// BuildConfigFull — exact vpn.py config. Fast, proven.
+func BuildConfigFull(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, policy *models.RoutingPolicy) SingBoxConfig {
+ return BuildConfigFullWithLocalProxy(server, mode, ruleSets, serverIPs, customBypass, LocalProxyPort, policy)
+}
+
+func BuildConfigFullWithLocalProxy(server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) SingBoxConfig {
+ if hy2, ok := findCompanionProtocol(server, "hysteria2"); ok && (server.Type == "vless-reality" || server.Type == "vless") {
+ return BuildSplitRoutingConfig(server, hy2, mode, ruleSets, serverIPs, customBypass, localProxyPort, policy)
+ }
+
+ effectivePolicy := EffectiveRoutingPolicy(policy)
+ bypassIPs := BuildBypassIPs(effectivePolicy, serverIPs)
+ bypassProcs := BuildBypassProcesses(effectivePolicy, customBypass)
+
+ var rules []map[string]any
+ rules = append(rules, map[string]any{"action": "sniff"})
+ rules = append(rules, map[string]any{"protocol": "dns", "action": "hijack-dns"})
+ rules = append(rules, map[string]any{"ip_is_private": true, "outbound": "direct"})
+ rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ReservedCIDRs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"ip_cidr": bypassIPs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"process_name": bypassProcs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.WindowsNCSIDomains, "outbound": "direct"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.LocalDomainSuffixes, "outbound": "direct"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.InfraBypassDomains, "outbound": "direct"})
+ rules = append(rules, map[string]any{"process_path_regex": effectivePolicy.LovenseProcessRegex, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ForcedProxyIPs, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"process_name": effectivePolicy.TelegramProcesses, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"process_path_regex": effectivePolicy.TelegramProcessRegex, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.TelegramDomains, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"domain_regex": effectivePolicy.TelegramDomainRegex, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.TelegramIPs, "outbound": "proxy"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.BlockedDomains, "outbound": "proxy"})
+
+ for _, r := range mode.Rules {
+ rule := map[string]any{"outbound": r.Outbound}
+ if len(r.DomainSuffix) > 0 {
+ rule["domain_suffix"] = r.DomainSuffix
+ }
+ if len(r.DomainRegex) > 0 {
+ rule["domain_regex"] = r.DomainRegex
+ }
+ if len(r.IPCIDR) > 0 {
+ rule["ip_cidr"] = r.IPCIDR
+ }
+ if len(r.RuleSet) > 0 {
+ rule["rule_set"] = r.RuleSet
+ }
+ if len(r.Network) > 0 {
+ rule["network"] = r.Network
+ }
+ if len(r.PortRange) > 0 {
+ rule["port_range"] = r.PortRange
+ }
+ rules = append(rules, rule)
+ }
+
+ if len(effectivePolicy.PreferDirectProcesses) > 0 {
+ rules = append(rules, map[string]any{"process_name": effectivePolicy.PreferDirectProcesses, "outbound": "direct"})
+ }
+
+ var ruleSetDefs []map[string]any
+ for _, rs := range ruleSets {
+ if rs.URL == "" {
+ continue
+ }
+ ruleSetDefs = append(ruleSetDefs, map[string]any{
+ "tag": rs.Tag, "type": "local", "format": rs.Format,
+ "path": rs.LocalPath,
+ })
+ }
+
+ route := map[string]any{
+ "auto_detect_interface": true,
+ "final": mode.Final,
+ "rules": rules,
+ }
+ if len(ruleSetDefs) > 0 {
+ route["rule_set"] = ruleSetDefs
+ }
+
+ return SingBoxConfig{
+ DNS: map[string]any{
+ "servers": []map[string]any{
+ {"tag": "proxy-dns", "type": "https", "server": "8.8.8.8", "detour": "proxy"},
+ {"tag": "direct-dns", "type": "https", "server": "1.1.1.1"},
+ },
+ "rules": []map[string]any{
+ {"outbound": "proxy", "server": "proxy-dns"},
+ {"outbound": "direct", "server": "direct-dns"},
+ },
+ "strategy": "ipv4_only",
+ },
+ Inbounds: []map[string]any{
+ {
+ "type": "tun",
+ "tag": "tun-in",
+ "interface_name": TunInterfaceName,
+ "address": []string{"172.19.0.1/30"},
+ "auto_route": true,
+ "strict_route": false,
+ "stack": "gvisor",
+ },
+ {
+ "type": "socks",
+ "tag": "socks-in",
+ "listen": LocalProxyHost,
+ "listen_port": defaultInt(localProxyPort, LocalProxyPort),
+ },
+ },
+ Outbounds: []map[string]any{
+ BuildOutbound(server),
+ {"type": "direct", "tag": "direct"},
+ },
+ Route: route,
+ Experimental: map[string]any{
+ "cache_file": map[string]any{
+ "enabled": true,
+ "path": "cache.db",
+ },
+ },
+ }
+}
+
+func BuildSplitRoutingConfig(vlessServer models.Server, hy2Server models.Server, mode Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) SingBoxConfig {
+ effectivePolicy := EffectiveRoutingPolicy(policy)
+ bypassIPs := BuildBypassIPs(effectivePolicy, serverIPs)
+ bypassProcs := BuildBypassProcesses(effectivePolicy, customBypass)
+
+ var rules []map[string]any
+ rules = append(rules, map[string]any{"action": "sniff"})
+ rules = append(rules, map[string]any{"protocol": "dns", "action": "hijack-dns"})
+ rules = append(rules, map[string]any{"ip_is_private": true, "outbound": "direct"})
+ rules = append(rules, map[string]any{"ip_cidr": effectivePolicy.ReservedCIDRs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"ip_cidr": bypassIPs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"process_name": bypassProcs, "outbound": "direct"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.WindowsNCSIDomains, "outbound": "direct"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.LocalDomainSuffixes, "outbound": "direct"})
+ rules = append(rules, map[string]any{"domain_suffix": effectivePolicy.InfraBypassDomains, "outbound": "direct"})
+ rules = appendSplitProxyRule(rules, map[string]any{"process_path_regex": effectivePolicy.LovenseProcessRegex})
+ rules = appendSplitProxyRule(rules, map[string]any{"ip_cidr": effectivePolicy.ForcedProxyIPs})
+ rules = appendSplitProxyRule(rules, map[string]any{"process_name": effectivePolicy.TelegramProcesses})
+ rules = appendSplitProxyRule(rules, map[string]any{"process_path_regex": effectivePolicy.TelegramProcessRegex})
+ rules = appendSplitProxyRule(rules, map[string]any{"domain_suffix": effectivePolicy.TelegramDomains})
+ rules = appendSplitProxyRule(rules, map[string]any{"domain_regex": effectivePolicy.TelegramDomainRegex})
+ rules = appendSplitProxyRule(rules, map[string]any{"ip_cidr": effectivePolicy.TelegramIPs})
+ rules = appendSplitProxyRule(rules, map[string]any{"domain_suffix": effectivePolicy.BlockedDomains})
+
+ for _, r := range mode.Rules {
+ rule := map[string]any{}
+ if len(r.DomainSuffix) > 0 {
+ rule["domain_suffix"] = r.DomainSuffix
+ }
+ if len(r.DomainRegex) > 0 {
+ rule["domain_regex"] = r.DomainRegex
+ }
+ if len(r.IPCIDR) > 0 {
+ rule["ip_cidr"] = r.IPCIDR
+ }
+ if len(r.RuleSet) > 0 {
+ rule["rule_set"] = r.RuleSet
+ }
+ if len(r.Network) > 0 {
+ rule["network"] = r.Network
+ }
+ if len(r.PortRange) > 0 {
+ rule["port_range"] = r.PortRange
+ }
+ if r.Outbound == "proxy" {
+ rules = appendSplitProxyRule(rules, rule)
+ } else {
+ rule["outbound"] = r.Outbound
+ rules = append(rules, rule)
+ }
+ }
+
+ if len(effectivePolicy.PreferDirectProcesses) > 0 {
+ rules = append(rules, map[string]any{"process_name": effectivePolicy.PreferDirectProcesses, "outbound": "direct"})
+ }
+
+ if mode.Final == "proxy" {
+ rules = append(rules,
+ map[string]any{"network": []string{"udp"}, "outbound": "hysteria2-out"},
+ map[string]any{"network": []string{"tcp"}, "outbound": "vless-out"},
+ )
+ }
+
+ var ruleSetDefs []map[string]any
+ for _, rs := range ruleSets {
+ if rs.URL == "" {
+ continue
+ }
+ ruleSetDefs = append(ruleSetDefs, map[string]any{
+ "tag": rs.Tag, "type": "remote", "format": rs.Format,
+ "url": rs.URL, "download_detour": "direct", "update_interval": "1d",
+ })
+ }
+
+ route := map[string]any{
+ "auto_detect_interface": true,
+ "final": splitFinalOutbound(mode.Final),
+ "rules": rules,
+ "default_domain_resolver": map[string]any{
+ "server": "direct-dns",
+ "strategy": "ipv4_only",
+ },
+ }
+ if len(ruleSetDefs) > 0 {
+ route["rule_set"] = ruleSetDefs
+ }
+
+ return SingBoxConfig{
+ DNS: map[string]any{
+ "servers": []map[string]any{
+ {"tag": "proxy-dns", "type": "https", "server": "8.8.8.8", "detour": "vless-out"},
+ {"tag": "direct-dns", "type": "udp", "server": "1.1.1.1", "server_port": 53},
+ },
+ "rules": []map[string]any{
+ {"outbound": "vless-out", "server": "proxy-dns"},
+ {"outbound": "hysteria2-out", "server": "proxy-dns"},
+ {"outbound": "direct", "server": "direct-dns"},
+ },
+ "strategy": "ipv4_only",
+ },
+ Inbounds: []map[string]any{
+ {
+ "type": "tun",
+ "tag": "tun-in",
+ "interface_name": TunInterfaceName,
+ "address": []string{"172.19.0.1/30"},
+ "auto_route": true,
+ "strict_route": false,
+ "stack": "gvisor",
+ },
+ {
+ "type": "socks",
+ "tag": "socks-in",
+ "listen": LocalProxyHost,
+ "listen_port": defaultInt(localProxyPort, LocalProxyPort),
+ },
+ },
+ Outbounds: []map[string]any{
+ BuildOutboundWithTag(vlessServer, "vless-out"),
+ BuildOutboundWithTag(hy2Server, "hysteria2-out"),
+ {"type": "direct", "tag": "direct"},
+ },
+ Route: route,
+ Experimental: map[string]any{
+ "cache_file": map[string]any{
+ "enabled": true,
+ "path": "cache.db",
+ },
+ },
+ }
+}
+
+func findCompanionProtocol(server models.Server, protocolType string) (models.Server, bool) {
+ for _, companion := range server.Companions {
+ if companion.Type == protocolType {
+ return companion, true
+ }
+ }
+ return models.Server{}, false
+}
+
+func splitFinalOutbound(final string) string {
+ if final == "proxy" {
+ return "vless-out"
+ }
+ return final
+}
+
+func appendSplitProxyRule(rules []map[string]any, base map[string]any) []map[string]any {
+ if rule, ok := splitRuleForNetwork(base, "tcp", "vless-out"); ok {
+ rules = append(rules, rule)
+ }
+ if rule, ok := splitRuleForNetwork(base, "udp", "hysteria2-out"); ok {
+ rules = append(rules, rule)
+ }
+ return rules
+}
+
+func splitRuleForNetwork(base map[string]any, network string, outbound string) (map[string]any, bool) {
+ rule := copyRule(base)
+ if networks, ok := rule["network"].([]string); ok && len(networks) > 0 {
+ if !containsString(networks, network) {
+ return nil, false
+ }
+ rule["network"] = []string{network}
+ } else {
+ rule["network"] = []string{network}
+ }
+ rule["outbound"] = outbound
+ return rule, true
+}
+
+func copyRule(in map[string]any) map[string]any {
+ out := make(map[string]any, len(in)+1)
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+func containsString(values []string, target string) bool {
+ for _, value := range values {
+ if value == target {
+ return true
+ }
+ }
+ return false
+}
+
+func defaultInt(value, fallback int) int {
+ if value > 0 {
+ return value
+ }
+ return fallback
+}
diff --git a/internal/config/builder_test.go b/internal/config/builder_test.go
new file mode 100644
index 0000000..0d46659
--- /dev/null
+++ b/internal/config/builder_test.go
@@ -0,0 +1,431 @@
+package config_test
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+
+ "vpnem/internal/config"
+ "vpnem/internal/models"
+)
+
+func TestBuildConfigSocks(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-1", Region: "NL", Type: "socks",
+ Server: "5.180.97.200", ServerPort: 54101, UDPOverTCP: true,
+ }
+ mode := *config.ModeByName("Lovense + OBS + AnyDesk + Discord")
+ ruleSets := []models.RuleSet{}
+
+ cfg := config.BuildConfig(server, mode, ruleSets, []string{"5.180.97.200"})
+
+ data, err := json.Marshal(cfg)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ s := string(data)
+
+ // Verify outbound type
+ if !strings.Contains(s, `"type":"socks"`) {
+ t.Error("expected socks outbound")
+ }
+ if !strings.Contains(s, `"type":"socks"`) {
+ t.Error("expected local socks inbound")
+ }
+ if !strings.Contains(s, `"listen":"127.0.0.1"`) {
+ t.Error("expected local socks proxy listen host")
+ }
+ if !strings.Contains(s, `"listen_port":10800`) && !strings.Contains(s, `"listen_port": 10800`) {
+ t.Error("expected local socks proxy on port 10800")
+ }
+ // Verify bypass processes present
+ if !strings.Contains(s, "chromium.exe") {
+ t.Error("expected chromium.exe in direct bypass list")
+ }
+ if !strings.Contains(s, "Performer Application v5.x.exe") {
+ t.Error("expected Performer Application v5.x.exe in direct bypass list")
+ }
+ if !strings.Contains(s, "Яндекс Музыка.exe") {
+ t.Error("expected Яндекс Музыка.exe in direct bypass list")
+ }
+ if strings.Contains(s, "chrome.exe") {
+ t.Error("did not expect chrome.exe in direct bypass list")
+ }
+ if strings.Contains(s, "firefox.exe") {
+ t.Error("did not expect firefox.exe in direct bypass list")
+ }
+ if strings.Contains(s, "msedgewebview2.exe") {
+ t.Error("did not expect msedgewebview2.exe in direct bypass list")
+ }
+ if !strings.Contains(s, "obs64.exe") {
+ t.Error("expected obs64.exe in config rules")
+ }
+ // Verify Lovense regex
+ if !strings.Contains(s, "lovense") {
+ t.Error("expected lovense process regex")
+ }
+ // Verify ip_is_private
+ if !strings.Contains(s, "ip_is_private") {
+ t.Error("expected ip_is_private rule")
+ }
+ // Verify NCSI domains
+ if !strings.Contains(s, "msftconnecttest.com") {
+ t.Error("expected NCSI domain")
+ }
+ // Verify Telegram
+ if !strings.Contains(s, "telegram.org") {
+ t.Error("expected telegram domains")
+ }
+ if !strings.Contains(s, "Telegram.exe") {
+ t.Error("expected Telegram.exe process rule")
+ }
+ // Verify Discord IPs
+ if !strings.Contains(s, "162.159.130.234/32") {
+ t.Error("expected discord IPs")
+ }
+ // Verify final is direct
+ if !strings.Contains(s, `"final":"direct"`) {
+ t.Error("expected final: direct")
+ }
+ // Verify TUN config
+ if !strings.Contains(s, "vpnem") {
+ t.Error("expected TUN interface name vpnem")
+ }
+ // Verify DNS
+ if !strings.Contains(s, "proxy-dns") {
+ t.Error("expected proxy-dns server")
+ }
+ // Verify cache_file
+ if !strings.Contains(s, "cache_file") {
+ t.Error("expected cache_file in experimental")
+ }
+ // sing-box 1.12: sniff/hijack-dns are route actions, not inbound flags.
+ if strings.Contains(s, `"sniff":true`) {
+ t.Error("did not expect legacy inbound sniff flags in 1.12 config")
+ }
+ if strings.Contains(s, `"sniff_override_destination":true`) {
+ t.Error("did not expect legacy sniff_override_destination in 1.12 config")
+ }
+ if !strings.Contains(s, `"action":"sniff"`) {
+ t.Error("expected route sniff action in 1.12 config")
+ }
+ if !strings.Contains(s, `"action":"hijack-dns"`) {
+ t.Error("expected route hijack-dns action in 1.12 config")
+ }
+ // sing-box 1.12: DoH servers use type+server, not address URLs.
+ if strings.Contains(s, `dns-query`) {
+ t.Error("did not expect legacy dns-query URLs in 1.12 config")
+ }
+ if !strings.Contains(s, `"type":"https"`) {
+ t.Error("expected https DNS server type")
+ }
+ if !strings.Contains(s, `"server":"1.1.1.1"`) {
+ t.Error("expected 1.1.1.1 DoH server")
+ }
+ if !strings.Contains(s, "default_domain_resolver") {
+ t.Error("expected default_domain_resolver in route")
+ }
+}
+
+func TestBuildConfigVLESS(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-vless", Region: "NL", Type: "vless",
+ Server: "5.180.97.200", ServerPort: 443, UUID: "test-uuid",
+ TLS: &models.TLS{Enabled: true, ServerName: "test.example.com"},
+ Transport: &models.Transport{Type: "ws", Path: "/test"},
+ }
+ mode := *config.ModeByName("Full (All Traffic)")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"type":"vless"`) {
+ t.Error("expected vless outbound")
+ }
+ if !strings.Contains(s, "test-uuid") {
+ t.Error("expected uuid")
+ }
+ if !strings.Contains(s, `"final":"proxy"`) {
+ t.Error("expected final: proxy for Full mode")
+ }
+}
+
+func TestBuildConfigVLESSReality(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-reality",
+ Region: "NL",
+ Type: "vless-reality",
+ Server: "203.0.113.20",
+ ServerPort: 443,
+ UUID: "33333333-3333-3333-3333-333333333333",
+ TLS: &models.TLS{
+ Enabled: true,
+ ServerName: "login.microsoftonline.com",
+ Reality: &models.Reality{
+ Enabled: true,
+ PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0",
+ ShortID: "0123456789abcdef",
+ Fingerprint: "chrome",
+ },
+ },
+ }
+ mode := *config.ModeByName("Full (All Traffic)")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"type":"vless"`) {
+ t.Error("expected vless outbound for reality")
+ }
+ if !strings.Contains(s, `"public_key":"jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0"`) {
+ t.Error("expected reality public key")
+ }
+ if !strings.Contains(s, `"short_id":"0123456789abcdef"`) {
+ t.Error("expected reality short id")
+ }
+ if !strings.Contains(s, `"fingerprint":"chrome"`) {
+ t.Error("expected reality utls fingerprint")
+ }
+}
+
+func TestBuildConfigShadowsocks(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-ss", Region: "NL", Type: "shadowsocks",
+ Server: "5.180.97.200", ServerPort: 36728,
+ Method: "chacha20-ietf-poly1305", Password: "test-pass",
+ }
+ mode := *config.ModeByName("Discord Only")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"type":"shadowsocks"`) {
+ t.Error("expected shadowsocks outbound")
+ }
+ if !strings.Contains(s, "chacha20-ietf-poly1305") {
+ t.Error("expected method")
+ }
+}
+
+func TestBuildConfigVMess(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-vmess", Region: "NL", Type: "vmess",
+ Server: "nl.example.com", ServerPort: 8444, UUID: "22222222-2222-2222-2222-222222222222",
+ TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"},
+ Transport: &models.Transport{Type: "ws", Path: "/vmess"},
+ }
+ mode := *config.ModeByName("Full (All Traffic)")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"type":"vmess"`) {
+ t.Error("expected vmess outbound")
+ }
+ if !strings.Contains(s, "22222222-2222-2222-2222-222222222222") {
+ t.Error("expected vmess uuid")
+ }
+ if !strings.Contains(s, `"/vmess"`) {
+ t.Error("expected vmess ws path")
+ }
+}
+
+func TestBuildConfigHysteria2(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-hy2", Region: "NL", Type: "hysteria2",
+ Server: "nl.example.com", ServerPort: 9443, Password: "hy2-secret", ObfsPassword: "obfs-secret",
+ UpMbps: 80, DownMbps: 90,
+ TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com", Insecure: true, ALPN: []string{"h3"}, MinVersion: "1.3", MaxVersion: "1.3"},
+ }
+ mode := *config.ModeByName("Full (All Traffic)")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"type":"hysteria2"`) {
+ t.Error("expected hysteria2 outbound")
+ }
+ if !strings.Contains(s, `"password":"hy2-secret"`) {
+ t.Error("expected hysteria2 password")
+ }
+ if !strings.Contains(s, `"salamander"`) {
+ t.Error("expected hysteria2 obfs configuration")
+ }
+ if !strings.Contains(s, `"up_mbps":80`) && !strings.Contains(s, `"up_mbps": 80`) {
+ t.Error("expected hysteria2 up_mbps")
+ }
+ if !strings.Contains(s, `"insecure":true`) && !strings.Contains(s, `"insecure": true`) {
+ t.Error("expected hysteria2 tls.insecure")
+ }
+ if !strings.Contains(s, `"alpn":["h3"]`) && !strings.Contains(s, `"alpn": ["h3"]`) {
+ t.Error("expected hysteria2 tls alpn h3")
+ }
+ if !strings.Contains(s, `"min_version":"1.3"`) && !strings.Contains(s, `"min_version": "1.3"`) {
+ t.Error("expected hysteria2 tls min_version")
+ }
+}
+
+func TestBuildConfigSplitRealityHysteria2(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-multi",
+ Region: "NL",
+ Type: "vless-reality",
+ Server: "203.0.113.50",
+ ServerPort: 443,
+ UUID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
+ TLS: &models.TLS{
+ Enabled: true,
+ ServerName: "www.microsoft.com",
+ Reality: &models.Reality{
+ Enabled: true,
+ PublicKey: "pubkey",
+ ShortID: "abcdef1234567890",
+ Fingerprint: "chrome",
+ },
+ },
+ Companions: []models.Server{
+ {
+ Tag: "nl-multi-hysteria2",
+ Region: "NL",
+ Type: "hysteria2",
+ Server: "203.0.113.50",
+ ServerPort: 443,
+ Password: "hy2-secret",
+ ObfsPassword: "obfs-secret",
+ UpMbps: 100,
+ DownMbps: 100,
+ TLS: &models.TLS{
+ Enabled: true,
+ Insecure: true,
+ ALPN: []string{"h3"},
+ MinVersion: "1.3",
+ MaxVersion: "1.3",
+ },
+ },
+ },
+ }
+ mode := *config.ModeByName("Full (All Traffic)")
+
+ cfg := config.BuildConfig(server, mode, nil, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, `"tag":"vless-out"`) {
+ t.Fatal("expected vless-out outbound tag")
+ }
+ if !strings.Contains(s, `"tag":"hysteria2-out"`) {
+ t.Fatal("expected hysteria2-out outbound tag")
+ }
+ if !strings.Contains(s, `"network":["tcp"]`) || !strings.Contains(s, `"outbound":"vless-out"`) {
+ t.Fatal("expected tcp split routing rule")
+ }
+ if !strings.Contains(s, `"network":["udp"]`) || !strings.Contains(s, `"outbound":"hysteria2-out"`) {
+ t.Fatal("expected udp split routing rule")
+ }
+ if !strings.Contains(s, `"detour":"vless-out"`) {
+ t.Fatal("expected proxy DNS detour via vless-out")
+ }
+}
+
+func TestBuildConfigWithRuleSets(t *testing.T) {
+ server := models.Server{
+ Tag: "nl-1", Type: "socks", Server: "1.2.3.4", ServerPort: 1080,
+ }
+ mode := *config.ModeByName("Re-filter (обход блокировок РФ)")
+ ruleSets := []models.RuleSet{
+ {Tag: "refilter-domains", URL: "https://example.com/domains.srs", Format: "binary"},
+ {Tag: "refilter-ip", URL: "https://example.com/ip.srs", Format: "binary"},
+ {Tag: "discord-voice", URL: "https://example.com/discord.srs", Format: "binary"},
+ }
+
+ cfg := config.BuildConfig(server, mode, ruleSets, nil)
+ data, _ := json.Marshal(cfg)
+ s := string(data)
+
+ if !strings.Contains(s, "refilter-domains") {
+ t.Error("expected refilter-domains rule_set")
+ }
+ if !strings.Contains(s, "download_detour") {
+ t.Error("expected download_detour in rule_set")
+ }
+ if !strings.Contains(s, "update_interval") {
+ t.Error("expected update_interval in rule_set")
+ }
+}
+
+func TestBuildBypassIPs(t *testing.T) {
+ ips := config.BuildBypassIPs(nil, []string{"1.2.3.4", "5.180.97.200"})
+
+ found := false
+ for _, ip := range ips {
+ if ip == "1.2.3.4/32" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("expected dynamic server IP in bypass list")
+ }
+
+ // 5.180.97.200 is already in StaticBypassIPs, should not be duplicated
+ count := 0
+ for _, ip := range ips {
+ if ip == "5.180.97.200/32" {
+ count++
+ }
+ }
+ if count != 1 {
+ t.Errorf("expected 5.180.97.200/32 exactly once, got %d", count)
+ }
+}
+
+func TestBuildBypassIPsIgnoresHostnames(t *testing.T) {
+ ips := config.BuildBypassIPs(nil, []string{"xui5.em-sysadmin.xyz", "1.2.3.4"})
+
+ for _, ip := range ips {
+ if ip == "xui5.em-sysadmin.xyz/32" {
+ t.Fatal("expected hostname to be ignored in bypass IP list")
+ }
+ }
+}
+
+func TestAllModes(t *testing.T) {
+ modes := config.AllModes()
+ if len(modes) != 7 {
+ t.Errorf("expected 7 modes, got %d", len(modes))
+ }
+
+ names := config.ModeNames()
+ expected := []string{
+ "Lovense + OBS + AnyDesk",
+ "Lovense + OBS + AnyDesk + Discord",
+ "Lovense + OBS + AnyDesk + Discord + Teams",
+ "Discord Only",
+ "Full (All Traffic)",
+ "Re-filter (обход блокировок РФ)",
+ "Комбо (приложения + Re-filter)",
+ }
+ for i, name := range expected {
+ if names[i] != name {
+ t.Errorf("mode %d: expected %q, got %q", i, name, names[i])
+ }
+ }
+}
+
+func TestModeByName(t *testing.T) {
+ m := config.ModeByName("Full (All Traffic)")
+ if m == nil {
+ t.Fatal("expected to find Full mode")
+ }
+ if m.Final != "proxy" {
+ t.Errorf("Full mode final should be proxy, got %s", m.Final)
+ }
+
+ if config.ModeByName("nonexistent") != nil {
+ t.Error("expected nil for nonexistent mode")
+ }
+}
diff --git a/internal/config/bypass.go b/internal/config/bypass.go
new file mode 100644
index 0000000..9deadb7
--- /dev/null
+++ b/internal/config/bypass.go
@@ -0,0 +1,169 @@
+package config
+
+import (
+ "net/netip"
+
+ "vpnem/internal/models"
+)
+
+// BYPASS_PROCESSES — processes that go direct, bypassing TUN.
+// Ported 1:1 from vpn.py.
+var BypassProcesses = []string{
+ "QTranslate.exe",
+ "aspia_host.exe",
+ "aspia_host_service.exe",
+ "aspia_desktop_agent.exe",
+ "Performer Application v5.x.exe",
+ "chromium.exe",
+ "Яндекс Музыка.exe",
+}
+
+// PreferDirectProcesses should stay outside global bypass, but still avoid the proxy
+// unless a stronger blocked/refilter/forced rule matches first.
+var PreferDirectProcesses = []string{
+ "obs64.exe",
+}
+
+// ProxyableBrowserProcesses intentionally stay OUT of the default direct bypass list.
+// Their traffic should follow routing mode rules, otherwise Full/Re-filter modes
+// cannot proxy IP-check and blocked domains correctly.
+var ProxyableBrowserProcesses = []string{
+ "chrome.exe",
+ "firefox.exe",
+ "msedgewebview2.exe",
+}
+
+// LovenseProcessRegex — force Lovense through proxy regardless of mode.
+var LovenseProcessRegex = []string{"(?i).*lovense.*"}
+
+// BYPASS_IPS — VPN server IPs + service IPs, always direct.
+// NL servers, RU servers, misc.
+var StaticBypassIPs = []string{
+ // NL servers
+ "5.180.97.200/32", "5.180.97.199/32", "5.180.97.198/32",
+ "5.180.97.197/32", "5.180.97.181/32",
+ // RU servers
+ "84.252.100.166/32", "84.252.100.165/32", "84.252.100.161/32",
+ "84.252.100.117/32", "84.252.100.103/32",
+ // Misc
+ "109.107.175.41/32", "146.103.104.48/32", "77.105.138.163/32",
+ "91.84.113.225/32", "146.103.98.171/32", "94.103.88.252/32",
+ "178.20.44.93/32", "89.124.70.47/32",
+}
+
+// ReservedCIDRs — ranges not covered by ip_is_private.
+var ReservedCIDRs = []string{
+ "100.64.0.0/10", // CGNAT / Tailscale
+ "192.0.0.0/24", // IETF protocol assignments
+ "192.0.2.0/24", // TEST-NET-1
+ "198.51.100.0/24", // TEST-NET-2
+ "203.0.113.0/24", // TEST-NET-3
+ "240.0.0.0/4", // Reserved (Class E)
+ "255.255.255.255/32", // Broadcast
+}
+
+// LocalDomainSuffixes — local/mDNS domains, always direct.
+var LocalDomainSuffixes = []string{
+ "local", "localhost", "lan", "internal", "home.arpa",
+ "corp", "intranet", "test", "invalid", "example",
+ "home", "localdomain",
+}
+
+// WindowsNCSIDomains — Windows Network Connectivity Status Indicator.
+// Without these going direct, Windows shows "No Internet" warnings.
+var WindowsNCSIDomains = []string{
+ "msftconnecttest.com",
+ "msftncsi.com",
+}
+
+// ForcedProxyIPs — IPs that must always go through proxy.
+var ForcedProxyIPs = []string{
+ "65.21.33.248/32",
+ "91.132.135.38/32",
+}
+
+// Telegram — hardcoded, applied to ALL modes.
+var TelegramDomains = []string{
+ "telegram.org", "telegram.me", "t.me", "telegra.ph", "telegram.dog",
+}
+
+var TelegramDomainRegex = []string{
+ ".*telegram.*", `.*t\.me.*`,
+}
+
+var TelegramIPs = []string{
+ "91.108.56.0/22", "91.108.4.0/22", "91.108.8.0/22",
+ "91.108.16.0/22", "91.108.12.0/22", "149.154.160.0/20",
+ "91.105.192.0/23", "91.108.20.0/22", "185.76.151.0/24",
+}
+
+var TelegramProcesses = []string{
+ "Telegram.exe",
+}
+
+var TelegramProcessRegex = []string{
+ `(?i).*telegram.*\\telegram\.exe$`,
+}
+
+// ProxyDNSDomains — domains NOT in refilter-domains.srs but must resolve via proxy DNS.
+// refilter-domains.srs (81k+ domains) covers all RKN-blocked domains.
+// This list only has domains missing from .srs that we still need through proxy.
+var ProxyDNSDomains = []string{
+ // Business-specific (not RKN-blocked)
+ "lovense.com", "lovense-api.com", "lovense.club",
+ // Not in refilter but needed
+ "anthropic.com",
+ "igcdn.com", "fbsbx.com",
+ // IP check services (must show proxy exit IP)
+ "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io", "ipify.org",
+}
+
+// IPCheckDomains — domains used for exit IP verification.
+var IPCheckDomains = []string{
+ "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io",
+}
+
+// BuildBypassProcesses merges default + custom bypass processes.
+func BuildBypassProcesses(policy *models.RoutingPolicy, custom []string) []string {
+ effective := EffectiveRoutingPolicy(policy)
+ seen := make(map[string]bool, len(effective.AlwaysDirectProcesses)+len(custom))
+ result := make([]string, 0, len(effective.AlwaysDirectProcesses)+len(custom))
+ for _, p := range effective.AlwaysDirectProcesses {
+ if !seen[p] {
+ seen[p] = true
+ result = append(result, p)
+ }
+ }
+ for _, p := range custom {
+ if p != "" && !seen[p] {
+ seen[p] = true
+ result = append(result, p)
+ }
+ }
+ return result
+}
+
+// BuildBypassIPs merges static bypass IPs with dynamic server IPs.
+func BuildBypassIPs(policy *models.RoutingPolicy, serverIPs []string) []string {
+ effective := EffectiveRoutingPolicy(policy)
+ seen := make(map[string]bool, len(effective.StaticBypassIPs)+len(serverIPs))
+ result := make([]string, 0, len(effective.StaticBypassIPs)+len(serverIPs))
+
+ for _, ip := range effective.StaticBypassIPs {
+ if !seen[ip] {
+ seen[ip] = true
+ result = append(result, ip)
+ }
+ }
+ for _, ip := range serverIPs {
+ if _, err := netip.ParseAddr(ip); err != nil {
+ continue
+ }
+ cidr := ip + "/32"
+ if !seen[cidr] {
+ seen[cidr] = true
+ result = append(result, cidr)
+ }
+ }
+ return result
+}
diff --git a/internal/config/modes.go b/internal/config/modes.go
new file mode 100644
index 0000000..22f1d2e
--- /dev/null
+++ b/internal/config/modes.go
@@ -0,0 +1,176 @@
+package config
+
+// Mode defines a routing mode with its specific rules.
+type Mode struct {
+ Name string
+ Final string // "direct" or "proxy"
+ Rules []Rule
+}
+
+// Rule represents a single sing-box routing rule.
+type Rule struct {
+ DomainSuffix []string `json:"domain_suffix,omitempty"`
+ DomainRegex []string `json:"domain_regex,omitempty"`
+ IPCIDR []string `json:"ip_cidr,omitempty"`
+ RuleSet []string `json:"rule_set,omitempty"`
+ Network []string `json:"network,omitempty"`
+ PortRange []string `json:"port_range,omitempty"`
+ Outbound string `json:"outbound"`
+}
+
+// Discord IPs — ported 1:1 from vpn.py.
+var DiscordIPs = []string{
+ "162.159.130.234/32", "162.159.134.234/32", "162.159.133.234/32",
+ "162.159.135.234/32", "162.159.136.234/32", "162.159.137.232/32",
+ "162.159.135.232/32", "162.159.136.232/32", "162.159.138.232/32",
+ "162.159.128.233/32", "198.244.231.90/32", "162.159.129.233/32",
+ "162.159.130.233/32", "162.159.133.233/32", "162.159.134.233/32",
+ "162.159.135.233/32", "162.159.138.234/32", "162.159.137.234/32",
+ "162.159.134.232/32", "162.159.130.235/32", "162.159.129.235/32",
+ "162.159.129.232/32", "162.159.128.235/32", "162.159.130.232/32",
+ "162.159.133.232/32", "162.159.128.232/32", "34.126.226.51/32",
+ // Voice
+ "66.22.243.0/24", "64.233.165.94/32", "35.207.188.57/32",
+ "35.207.81.249/32", "35.207.171.222/32", "195.62.89.0/24",
+ "66.22.192.0/18", "66.22.196.0/24", "66.22.197.0/24",
+ "66.22.198.0/24", "66.22.199.0/24", "66.22.216.0/24",
+ "66.22.217.0/24", "66.22.237.0/24", "66.22.238.0/24",
+ "66.22.241.0/24", "66.22.242.0/24", "66.22.244.0/24",
+ "64.71.8.96/29", "34.0.240.0/24", "34.0.241.0/24",
+ "34.0.242.0/24", "34.0.243.0/24", "34.0.244.0/24",
+ "34.0.245.0/24", "34.0.246.0/24", "34.0.247.0/24",
+ "34.0.248.0/24", "34.0.249.0/24", "34.0.250.0/24",
+ "34.0.251.0/24", "12.129.184.160/29", "138.128.136.0/21",
+ "162.158.0.0/15", "172.64.0.0/13", "34.0.0.0/15",
+ "34.2.0.0/15", "35.192.0.0/12", "35.208.0.0/12",
+ "5.200.14.128/25",
+}
+
+var DiscordDomains = []string{
+ "discord.com", "discord.gg", "discordapp.com",
+ "discord.media", "discordapp.net", "discord.net",
+}
+
+var DiscordDomainRegex = []string{".*discord.*"}
+
+var TeamsDomains = []string{
+ "teams.microsoft.com", "teams.cloud.microsoft", "lync.com",
+ "skype.com", "keydelivery.mediaservices.windows.net",
+ "streaming.mediaservices.windows.net",
+}
+
+var TeamsIPs = []string{
+ "52.112.0.0/14", "52.122.0.0/15",
+}
+
+var TeamsDomainRegex = []string{
+ `.*teams\.microsoft.*`, ".*lync.*", ".*skype.*",
+}
+
+var LovenseDomains = []string{
+ "lovense-api.com", "lovense.com", "lovense.club",
+}
+
+var LovenseDomainRegex = []string{".*lovense.*"}
+
+var OBSDomains = []string{"obsproject.com"}
+var OBSDomainRegex = []string{".*obsproject.*"}
+
+var AnyDeskDomains = []string{
+ "anydesk.com", "anydesk.com.cn", "net.anydesk.com",
+}
+
+var AnyDeskDomainRegex = []string{".*anydesk.*"}
+
+// AllModes returns all available routing modes.
+func AllModes() []Mode {
+ baseDomains := append(append(append(
+ LovenseDomains, OBSDomains...), AnyDeskDomains...), IPCheckDomains...)
+ baseRegex := append(append(
+ LovenseDomainRegex, OBSDomainRegex...), AnyDeskDomainRegex...)
+
+ discordDomains := append(append([]string{}, baseDomains...), DiscordDomains...)
+ discordRegex := append(append([]string{}, baseRegex...), DiscordDomainRegex...)
+
+ teamsDomains := append(append([]string{}, discordDomains...), TeamsDomains...)
+ teamsRegex := append(append([]string{}, discordRegex...), TeamsDomainRegex...)
+
+ return []Mode{
+ {
+ Name: "Lovense + OBS + AnyDesk",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: baseDomains, DomainRegex: baseRegex, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Lovense + OBS + AnyDesk + Discord",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: discordDomains, DomainRegex: discordRegex, Outbound: "proxy"},
+ {IPCIDR: DiscordIPs, Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Lovense + OBS + AnyDesk + Discord + Teams",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: teamsDomains, DomainRegex: teamsRegex, Outbound: "proxy"},
+ {IPCIDR: append(append([]string{}, DiscordIPs...), TeamsIPs...), Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"3478:3481", "50000:65535"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Discord Only",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: append(append([]string{}, DiscordDomains...), IPCheckDomains...), DomainRegex: DiscordDomainRegex, Outbound: "proxy"},
+ {IPCIDR: DiscordIPs, Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Full (All Traffic)",
+ Final: "proxy",
+ Rules: nil,
+ },
+ {
+ Name: "Re-filter (обход блокировок РФ)",
+ Final: "direct",
+ Rules: []Rule{
+ {RuleSet: []string{"refilter-domains", "refilter-ip", "discord-voice"}, Outbound: "proxy"},
+ },
+ },
+ {
+ Name: "Комбо (приложения + Re-filter)",
+ Final: "direct",
+ Rules: []Rule{
+ {DomainSuffix: discordDomains, DomainRegex: discordRegex, Outbound: "proxy"},
+ {IPCIDR: DiscordIPs, Outbound: "proxy"},
+ {Network: []string{"udp"}, PortRange: []string{"50000:65535"}, Outbound: "proxy"},
+ {RuleSet: []string{"refilter-domains", "refilter-ip", "discord-voice"}, Outbound: "proxy"},
+ },
+ },
+ }
+}
+
+// ModeByName finds a mode by name, returns nil if not found.
+func ModeByName(name string) *Mode {
+ for _, m := range AllModes() {
+ if m.Name == name {
+ return &m
+ }
+ }
+ return nil
+}
+
+// ModeNames returns all available mode names.
+func ModeNames() []string {
+ modes := AllModes()
+ names := make([]string, len(modes))
+ for i, m := range modes {
+ names[i] = m.Name
+ }
+ return names
+}
diff --git a/internal/config/outbounds.go b/internal/config/outbounds.go
new file mode 100644
index 0000000..582b625
--- /dev/null
+++ b/internal/config/outbounds.go
@@ -0,0 +1,212 @@
+package config
+
+import "vpnem/internal/models"
+
+type InboundConfig map[string]any
+
+func BuildOutbound(server models.Server) map[string]any {
+ return BuildOutboundWithTag(server, "proxy")
+}
+
+func BuildOutboundWithTag(server models.Server, tag string) map[string]any {
+ switch server.Type {
+ case "vless":
+ return buildVLESSOutbound(server, tag)
+ case "vless-reality":
+ return buildVLESSRealityOutbound(server, tag)
+ case "vmess":
+ return buildVMessOutbound(server, tag)
+ case "shadowsocks":
+ return buildShadowsocksOutbound(server, tag)
+ case "hysteria2":
+ return buildHysteria2Outbound(server, tag)
+ default:
+ return buildSOCKSOutbound(server, tag)
+ }
+}
+
+func BuildHysteria2Inbound(_ any, port int, password string, obfsPassword string, upMbps int, downMbps int, certPath string, keyPath string) (*InboundConfig, error) {
+ if password == "" {
+ return nil, errConfig("hysteria2 inbound requires password")
+ }
+ if certPath == "" || keyPath == "" {
+ return nil, errConfig("hysteria2 inbound requires certificate and key paths")
+ }
+ inbound := InboundConfig{
+ "type": "hysteria2",
+ "tag": "hysteria2-in",
+ "listen": "::",
+ "listen_port": port,
+ "users": []map[string]any{
+ {"name": "user-01", "password": password},
+ },
+ "tls": map[string]any{
+ "enabled": true,
+ "alpn": []string{"h3"},
+ "min_version": "1.3",
+ "max_version": "1.3",
+ "certificate_path": certPath,
+ "key_path": keyPath,
+ },
+ }
+ if upMbps > 0 {
+ inbound["up_mbps"] = upMbps
+ }
+ if downMbps > 0 {
+ inbound["down_mbps"] = downMbps
+ }
+ if obfsPassword != "" {
+ inbound["obfs"] = map[string]any{
+ "type": "salamander",
+ "password": obfsPassword,
+ }
+ }
+ return &inbound, nil
+}
+
+func buildVLESSOutbound(server models.Server, tag string) map[string]any {
+ outbound := map[string]any{
+ "type": "vless", "tag": tag,
+ "server": server.Server, "server_port": server.ServerPort, "uuid": server.UUID,
+ }
+ applyTLS(outbound, server.TLS)
+ applyTransport(outbound, server.Transport)
+ return outbound
+}
+
+func buildVLESSRealityOutbound(server models.Server, tag string) map[string]any {
+ outbound := map[string]any{
+ "type": "vless", "tag": tag,
+ "server": server.Server, "server_port": server.ServerPort, "uuid": server.UUID,
+ }
+ applyTLS(outbound, server.TLS)
+ return outbound
+}
+
+func buildVMessOutbound(server models.Server, tag string) map[string]any {
+ outbound := map[string]any{
+ "type": "vmess", "tag": tag,
+ "server": server.Server, "server_port": server.ServerPort,
+ "uuid": server.UUID, "security": "auto", "alter_id": 0,
+ }
+ applyTLS(outbound, server.TLS)
+ applyTransport(outbound, server.Transport)
+ return outbound
+}
+
+func buildShadowsocksOutbound(server models.Server, tag string) map[string]any {
+ return map[string]any{
+ "type": "shadowsocks", "tag": tag,
+ "server": server.Server, "server_port": server.ServerPort,
+ "method": server.Method, "password": server.Password,
+ }
+}
+
+func buildHysteria2Outbound(server models.Server, tag string) map[string]any {
+ outbound := map[string]any{
+ "type": "hysteria2", "tag": tag,
+ "server": server.Server, "server_port": server.ServerPort,
+ "password": server.Password,
+ }
+ if server.UpMbps > 0 {
+ outbound["up_mbps"] = server.UpMbps
+ }
+ if server.DownMbps > 0 {
+ outbound["down_mbps"] = server.DownMbps
+ }
+ if server.ObfsPassword != "" {
+ outbound["obfs"] = map[string]any{"type": "salamander", "password": server.ObfsPassword}
+ }
+ tlsConfig := map[string]any{
+ "enabled": true,
+ "insecure": true,
+ "alpn": []string{"h3"},
+ "min_version": "1.3",
+ "max_version": "1.3",
+ }
+ if server.TLS != nil {
+ if server.TLS.ServerName != "" {
+ tlsConfig["server_name"] = server.TLS.ServerName
+ }
+ if len(server.TLS.ALPN) > 0 {
+ tlsConfig["alpn"] = server.TLS.ALPN
+ }
+ if server.TLS.MinVersion != "" {
+ tlsConfig["min_version"] = server.TLS.MinVersion
+ }
+ if server.TLS.MaxVersion != "" {
+ tlsConfig["max_version"] = server.TLS.MaxVersion
+ }
+ if server.TLS.Insecure {
+ tlsConfig["insecure"] = true
+ }
+ }
+ outbound["tls"] = tlsConfig
+ return outbound
+}
+
+func buildSOCKSOutbound(server models.Server, tag string) map[string]any {
+ return map[string]any{
+ "type": "socks", "tag": tag,
+ "server": server.Server, "server_port": server.ServerPort,
+ "udp_over_tcp": server.UDPOverTCP,
+ }
+}
+
+func applyTLS(outbound map[string]any, tls *models.TLS) {
+ if tls == nil {
+ return
+ }
+ tlsConfig := map[string]any{
+ "enabled": tls.Enabled,
+ "server_name": tls.ServerName,
+ }
+ if tls.Insecure {
+ tlsConfig["insecure"] = true
+ }
+ if len(tls.ALPN) > 0 {
+ tlsConfig["alpn"] = tls.ALPN
+ }
+ if tls.MinVersion != "" {
+ tlsConfig["min_version"] = tls.MinVersion
+ }
+ if tls.MaxVersion != "" {
+ tlsConfig["max_version"] = tls.MaxVersion
+ }
+ if tls.Reality != nil && tls.Reality.Enabled {
+ tlsConfig["reality"] = map[string]any{
+ "enabled": true,
+ "public_key": tls.Reality.PublicKey,
+ "short_id": tls.Reality.ShortID,
+ }
+ if tls.Reality.Fingerprint != "" {
+ tlsConfig["utls"] = map[string]any{
+ "enabled": true,
+ "fingerprint": tls.Reality.Fingerprint,
+ }
+ }
+ }
+ outbound["tls"] = tlsConfig
+}
+
+func errConfig(message string) error {
+ return &configError{message: message}
+}
+
+type configError struct {
+ message string
+}
+
+func (e *configError) Error() string {
+ return e.message
+}
+
+func applyTransport(outbound map[string]any, transport *models.Transport) {
+ if transport == nil {
+ return
+ }
+ outbound["transport"] = map[string]any{
+ "type": transport.Type,
+ "path": transport.Path,
+ }
+}
diff --git a/internal/config/policy.go b/internal/config/policy.go
new file mode 100644
index 0000000..bcf8f71
--- /dev/null
+++ b/internal/config/policy.go
@@ -0,0 +1,102 @@
+package config
+
+import "vpnem/internal/models"
+
+var defaultBlockedDomains = []string{
+ "telegram.org", "t.me", "telegram.me", "telegra.ph", "telegram.dog",
+ "web.telegram.org",
+ "discord.com", "discord.gg", "discordapp.com", "discordapp.net",
+ "instagram.com", "cdninstagram.com", "ig.me", "igcdn.com",
+ "facebook.com", "fb.com", "fbcdn.net", "fbsbx.com", "fb.me",
+ "whatsapp.com", "whatsapp.net",
+ "twitter.com", "x.com", "twimg.com", "t.co",
+ "openai.com", "chatgpt.com", "oaistatic.com", "oaiusercontent.com",
+ "claude.ai", "anthropic.com",
+ "youtube.com", "googlevideo.com", "youtu.be", "ggpht.com", "ytimg.com",
+ "gstatic.com", "doubleclick.net", "googleadservices.com",
+ "stripchat.com", "stripchat.global", "ststandard.com", "strpssts-ana.com",
+ "strpst.com", "striiiipst.com",
+ "chaturbate.com", "highwebmedia.com", "cb.dev",
+ "camsoda.com", "cam4.com", "cam101.com",
+ "bongamodels.com", "flirt4free.com", "privatecams.com",
+ "streamray.com", "cams.com", "homelivesex.com",
+ "skyprivate.com", "mywebcamroom.com", "livemediahost.com",
+ "xcdnpro.com", "mmcdn.com", "vscdns.com", "bgicdn.com", "bgmicdn.com",
+ "doppiocdn.com", "doppiocdn.net", "doppiostreams.com",
+ "fanclubs.tech", "my.club", "chapturist.com",
+ "moengage.com", "amplitude.com", "dwin1.com",
+ "eizzih.com", "loo3laej.com", "iesnare.com",
+ "hytto.com", "zendesk.com",
+ "lovense.com", "lovense-api.com", "lovense.club",
+ "bitrix24.ru", "bitrix24.com",
+ "cloudflare.com",
+ "viber.com", "linkedin.com", "spotify.com",
+ "ntc.party", "ipify.org",
+ "rutracker.org", "rutracker.net", "rutracker.me",
+ "4pda.to", "kinozal.tv", "nnmclub.to",
+ "protonmail.com", "proton.me", "tutanota.com",
+ "medium.com", "archive.org", "soundcloud.com", "twitch.tv",
+ "ifconfig.me", "ifconfig.co", "icanhazip.com", "ipinfo.io",
+ "em-mail.ru",
+}
+
+func DefaultRoutingPolicy() *models.RoutingPolicy {
+ return &models.RoutingPolicy{
+ Version: "2026-04-04",
+ AlwaysDirectProcesses: append([]string{}, BypassProcesses...),
+ PreferDirectProcesses: append([]string{}, PreferDirectProcesses...),
+ ProxyableBrowserProcesses: append([]string{}, ProxyableBrowserProcesses...),
+ LovenseProcessRegex: append([]string{}, LovenseProcessRegex...),
+ StaticBypassIPs: append([]string{}, StaticBypassIPs...),
+ ReservedCIDRs: append([]string{}, ReservedCIDRs...),
+ LocalDomainSuffixes: append([]string{}, LocalDomainSuffixes...),
+ WindowsNCSIDomains: append([]string{}, WindowsNCSIDomains...),
+ InfraBypassDomains: []string{"em-sysadmin.xyz"},
+ ForcedProxyIPs: append([]string{}, ForcedProxyIPs...),
+ TelegramProcesses: append([]string{}, TelegramProcesses...),
+ TelegramProcessRegex: append([]string{}, TelegramProcessRegex...),
+ TelegramDomains: append([]string{}, TelegramDomains...),
+ TelegramDomainRegex: append([]string{}, TelegramDomainRegex...),
+ TelegramIPs: append([]string{}, TelegramIPs...),
+ BlockedDomains: append([]string{}, defaultBlockedDomains...),
+ ProxyDNSDomains: append([]string{}, ProxyDNSDomains...),
+ IPCheckDomains: append([]string{}, IPCheckDomains...),
+ }
+}
+
+func EffectiveRoutingPolicy(policy *models.RoutingPolicy) *models.RoutingPolicy {
+ if policy == nil {
+ return DefaultRoutingPolicy()
+ }
+
+ effective := *DefaultRoutingPolicy()
+ if policy.Version != "" {
+ effective.Version = policy.Version
+ }
+ overrideStringSlice(&effective.AlwaysDirectProcesses, policy.AlwaysDirectProcesses)
+ overrideStringSlice(&effective.PreferDirectProcesses, policy.PreferDirectProcesses)
+ overrideStringSlice(&effective.ProxyableBrowserProcesses, policy.ProxyableBrowserProcesses)
+ overrideStringSlice(&effective.LovenseProcessRegex, policy.LovenseProcessRegex)
+ overrideStringSlice(&effective.StaticBypassIPs, policy.StaticBypassIPs)
+ overrideStringSlice(&effective.ReservedCIDRs, policy.ReservedCIDRs)
+ overrideStringSlice(&effective.LocalDomainSuffixes, policy.LocalDomainSuffixes)
+ overrideStringSlice(&effective.WindowsNCSIDomains, policy.WindowsNCSIDomains)
+ overrideStringSlice(&effective.InfraBypassDomains, policy.InfraBypassDomains)
+ overrideStringSlice(&effective.ForcedProxyIPs, policy.ForcedProxyIPs)
+ overrideStringSlice(&effective.TelegramProcesses, policy.TelegramProcesses)
+ overrideStringSlice(&effective.TelegramProcessRegex, policy.TelegramProcessRegex)
+ overrideStringSlice(&effective.TelegramDomains, policy.TelegramDomains)
+ overrideStringSlice(&effective.TelegramDomainRegex, policy.TelegramDomainRegex)
+ overrideStringSlice(&effective.TelegramIPs, policy.TelegramIPs)
+ overrideStringSlice(&effective.BlockedDomains, policy.BlockedDomains)
+ overrideStringSlice(&effective.ProxyDNSDomains, policy.ProxyDNSDomains)
+ overrideStringSlice(&effective.IPCheckDomains, policy.IPCheckDomains)
+ return &effective
+}
+
+func overrideStringSlice(dst *[]string, src []string) {
+ if src == nil {
+ return
+ }
+ *dst = append([]string{}, src...)
+}
diff --git a/internal/control/bootstrap.go b/internal/control/bootstrap.go
new file mode 100644
index 0000000..5eb6f4f
--- /dev/null
+++ b/internal/control/bootstrap.go
@@ -0,0 +1,369 @@
+package control
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+type BootstrapOptions struct {
+ StateDir string
+ DryRun bool
+}
+
+func BootstrapNode(ctx context.Context, runner SSHExecutor, node Node, opts BootstrapOptions) (*NodeState, error) {
+ for idx := range node.Protocols {
+ if err := ensureRealityProfile(&node.Protocols[idx]); err != nil {
+ return nil, err
+ }
+ if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil {
+ return nil, err
+ }
+ }
+ if err := ValidateNode(node); err != nil {
+ return nil, err
+ }
+
+ now := time.Now().UTC()
+ state := &NodeState{
+ NodeID: node.ID,
+ BootstrapStatus: "pending",
+ PublicHost: publicHost(node),
+ Services: serviceStatuses(node.Protocols, "configured"),
+ Metadata: map[string]any{
+ "provider": node.Provider,
+ "region": node.Region,
+ "dry_run": opts.DryRun,
+ },
+ }
+
+ if opts.DryRun {
+ state.BootstrapStatus = "planned"
+ state.LastBootstrapAt = &now
+ state.Metadata["release_id"] = buildReleaseID(now)
+ if err := SaveNodeState(opts.StateDir, *state); err != nil {
+ return nil, err
+ }
+ return state, nil
+ }
+
+ relID := buildReleaseID(now)
+ bundleDir, tarballPath, err := buildRuntimeBundle(node, relID)
+ if err != nil {
+ return nil, err
+ }
+ defer os.RemoveAll(bundleDir)
+ defer os.Remove(tarballPath)
+
+ result, err := runner.Run(ctx, node, RenderBootstrapPrepareScript())
+ if err != nil {
+ state.BootstrapStatus = "failed"
+ state.LastBootstrapAt = &now
+ state.Metadata["stderr"] = strings.TrimSpace(result.Stderr)
+ state.Metadata["stdout"] = strings.TrimSpace(result.Stdout)
+ if saveErr := SaveNodeState(opts.StateDir, *state); saveErr != nil {
+ return nil, fmt.Errorf("%w; save state: %v", err, saveErr)
+ }
+ return nil, err
+ }
+
+ remoteTarballPath := "/tmp/vpnem-node-" + node.ID + ".tar.gz"
+ if err := runner.CopyFile(ctx, node, tarballPath, remoteTarballPath); err != nil {
+ state.BootstrapStatus = "failed"
+ state.LastBootstrapAt = &now
+ state.Metadata["release_id"] = relID
+ state.Metadata["copy_error"] = err.Error()
+ if saveErr := SaveNodeState(opts.StateDir, *state); saveErr != nil {
+ return nil, fmt.Errorf("%w; save state: %v", err, saveErr)
+ }
+ return nil, err
+ }
+
+ result, err = runner.Run(ctx, node, RenderBootstrapFinalizeScript(node, relID, remoteTarballPath))
+ if err != nil {
+ state.BootstrapStatus = "failed"
+ state.LastBootstrapAt = &now
+ state.Metadata["stderr"] = strings.TrimSpace(result.Stderr)
+ state.Metadata["stdout"] = strings.TrimSpace(result.Stdout)
+ if saveErr := SaveNodeState(opts.StateDir, *state); saveErr != nil {
+ return nil, fmt.Errorf("%w; save state: %v", err, saveErr)
+ }
+ return nil, err
+ }
+
+ state.BootstrapStatus = "ready"
+ state.LastBootstrapAt = &now
+ state.Metadata["release_id"] = relID
+ state.Metadata["stdout"] = strings.TrimSpace(result.Stdout)
+ if err := SaveNodeState(opts.StateDir, *state); err != nil {
+ return nil, err
+ }
+ return state, nil
+}
+
+func CheckNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) {
+ now := time.Now().UTC()
+ result, err := runner.Check(ctx, node)
+ state := &NodeState{
+ NodeID: node.ID,
+ PublicHost: publicHost(node),
+ LastHealthCheckAt: &now,
+ Services: serviceStatuses(node.Protocols, "unknown"),
+ Metadata: map[string]any{},
+ }
+
+ if err != nil {
+ state.BootstrapStatus = "unreachable"
+ state.Metadata["stderr"] = strings.TrimSpace(result.Stderr)
+ if saveErr := SaveNodeState(stateDir, *state); saveErr != nil {
+ return nil, fmt.Errorf("%w; save state: %v", err, saveErr)
+ }
+ return nil, err
+ }
+
+ state.BootstrapStatus = "reachable"
+ state.Metadata["stdout"] = strings.TrimSpace(result.Stdout)
+
+ runtimeResult, runtimeErr := runner.Run(ctx, node, RenderHealthCheckScript(node))
+ if runtimeErr != nil {
+ state.Metadata["runtime_stderr"] = strings.TrimSpace(runtimeResult.Stderr)
+ state.Metadata["runtime_stdout"] = strings.TrimSpace(runtimeResult.Stdout)
+ } else {
+ services, metadata := parseHealthCheckOutput(runtimeResult.Stdout, node.Protocols)
+ if len(services) > 0 {
+ state.Services = services
+ }
+ for k, v := range metadata {
+ state.Metadata[k] = v
+ }
+ if healthy, ok := metadata["healthz_http_code"].(int); ok && healthy == 200 {
+ state.BootstrapStatus = "healthy"
+ } else if allServicesRunning(state.Services) {
+ state.BootstrapStatus = "ready"
+ }
+ }
+ if err := SaveNodeState(stateDir, *state); err != nil {
+ return nil, err
+ }
+ return state, nil
+}
+
+func RenderBootstrapPrepareScript() string {
+ var b strings.Builder
+ b.WriteString("set -eu\n")
+ b.WriteString("export DEBIAN_FRONTEND=noninteractive\n")
+ b.WriteString("mkdir -p /opt/vpnem-node/releases\n")
+ b.WriteString("if command -v apt-get >/dev/null 2>&1; then\n")
+ b.WriteString(" apt-get update\n")
+ b.WriteString(" apt-get install -y ca-certificates curl tar gzip openssl docker.io docker-compose || true\n")
+ b.WriteString("elif command -v dnf >/dev/null 2>&1; then\n")
+ b.WriteString(" dnf install -y ca-certificates curl tar gzip openssl docker docker-compose-plugin docker-compose || true\n")
+ b.WriteString("elif command -v pacman >/dev/null 2>&1; then\n")
+ b.WriteString(" pacman -Sy --noconfirm ca-certificates curl tar gzip openssl docker docker-compose || true\n")
+ b.WriteString("elif command -v apk >/dev/null 2>&1; then\n")
+ b.WriteString(" apk add --no-cache ca-certificates curl tar gzip openssl docker-cli-compose || true\n")
+ b.WriteString("fi\n")
+ b.WriteString("if command -v systemctl >/dev/null 2>&1; then systemctl enable --now docker || true; fi\n")
+ b.WriteString("if ! command -v docker >/dev/null 2>&1; then\n")
+ b.WriteString(" echo 'docker is not installed after bootstrap prepare' >&2\n")
+ b.WriteString(" exit 1\n")
+ b.WriteString("fi\n")
+ b.WriteString("printf 'vpnem-node bootstrap prepared\\n'\n")
+ return b.String()
+}
+
+func RenderBootstrapFinalizeScript(node Node, releaseID, remoteTarballPath string) string {
+ var b strings.Builder
+ releaseDir := "/opt/vpnem-node/releases/" + releaseID
+ b.WriteString("set -eu\n")
+ b.WriteString("mkdir -p " + releaseDir + "\n")
+ b.WriteString("tar -xzf " + remoteTarballPath + " -C " + releaseDir + "\n")
+ b.WriteString("ln -sfn " + releaseDir + " /opt/vpnem-node/current\n")
+ b.WriteString("rm -f " + remoteTarballPath + "\n")
+ b.WriteString("if ! command -v docker >/dev/null 2>&1; then\n")
+ b.WriteString(" echo 'docker is not installed on target node' >&2\n")
+ b.WriteString(" exit 1\n")
+ b.WriteString("fi\n")
+ b.WriteString("if docker compose version >/dev/null 2>&1; then\n")
+ b.WriteString(" docker compose -f /opt/vpnem-node/current/docker-compose.yml up -d --force-recreate\n")
+ b.WriteString("elif command -v docker-compose >/dev/null 2>&1; then\n")
+ b.WriteString(" docker-compose -f /opt/vpnem-node/current/docker-compose.yml up -d --force-recreate\n")
+ b.WriteString("else\n")
+ b.WriteString(" echo 'docker compose is not available on target node' >&2\n")
+ b.WriteString(" exit 1\n")
+ b.WriteString("fi\n")
+ b.WriteString("printf 'vpnem-node release ")
+ b.WriteString(shellQuoteValue(releaseID))
+ b.WriteString(" ready for ")
+ b.WriteString(shellQuoteValue(node.ID))
+ b.WriteString("\\n'\n")
+ return b.String()
+}
+
+func RenderHealthCheckScript(node Node) string {
+ var b strings.Builder
+ b.WriteString("set -eu\n")
+ b.WriteString("if [ -f /opt/vpnem-node/current/docker-compose.yml ]; then\n")
+ b.WriteString(" if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then\n")
+ b.WriteString(" docker compose -f /opt/vpnem-node/current/docker-compose.yml ps --format json 2>/dev/null || true\n")
+ b.WriteString(" elif command -v docker-compose >/dev/null 2>&1; then\n")
+ b.WriteString(" docker-compose -f /opt/vpnem-node/current/docker-compose.yml ps --format json 2>/dev/null || true\n")
+ b.WriteString(" fi\n")
+ b.WriteString(" if command -v docker >/dev/null 2>&1; then\n")
+ b.WriteString(" docker ps --format '{{json .}}' 2>/dev/null || true\n")
+ b.WriteString(" fi\n")
+ b.WriteString("fi\n")
+ if needsEdgeProxy(node) {
+ b.WriteString("printf 'HEALTHZ_HTTP_CODE='; ")
+ b.WriteString("curl -ks --resolve ")
+ b.WriteString(shellQuoteValue(node.Domain))
+ b.WriteString(":443:127.0.0.1 -o /dev/null -w '%{http_code}' https://")
+ b.WriteString(shellQuoteValue(node.Domain))
+ b.WriteString("/healthz || true\n")
+ }
+ if needsHysteria2HealthInbound(node) {
+ b.WriteString("printf 'HY2_MIXED_PORT='; ")
+ b.WriteString("curl -sS --max-time 5 --proxy socks5h://127.0.0.1:1080 https://ifconfig.me/ip || true\n")
+ }
+ return b.String()
+}
+
+func serviceStatuses(protocols []ProtocolProfile, status string) []ServiceStatus {
+ services := make([]ServiceStatus, 0, len(protocols))
+ for _, protocol := range protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ services = append(services, ServiceStatus{
+ Type: protocol.Type,
+ Status: status,
+ Port: protocol.Port,
+ })
+ }
+ return services
+}
+
+func parseHealthCheckOutput(stdout string, protocols []ProtocolProfile) ([]ServiceStatus, map[string]any) {
+ services := serviceStatuses(protocols, "unknown")
+ metadata := map[string]any{}
+ lines := strings.Split(stdout, "\n")
+ for _, line := range lines {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ if strings.HasPrefix(line, "HEALTHZ_HTTP_CODE=") {
+ codeStr := strings.TrimPrefix(line, "HEALTHZ_HTTP_CODE=")
+ if code, err := strconv.Atoi(codeStr); err == nil {
+ metadata["healthz_http_code"] = code
+ }
+ continue
+ }
+ if strings.HasPrefix(line, "HY2_MIXED_PORT=") {
+ value := strings.TrimSpace(strings.TrimPrefix(line, "HY2_MIXED_PORT="))
+ metadata["hy2_mixed_port"] = value
+ if value != "" {
+ markServicesByTypes(services, []string{"hysteria2"}, "running")
+ }
+ continue
+ }
+
+ var entry map[string]any
+ if err := jsonUnmarshalLine(line, &entry); err != nil {
+ continue
+ }
+ serviceName, _ := entry["Service"].(string)
+ state, _ := entry["State"].(string)
+ if serviceName == "" {
+ if labels, _ := entry["Labels"].(string); strings.Contains(labels, "com.docker.compose.service=sing-box") {
+ serviceName = "sing-box"
+ } else if names, _ := entry["Names"].(string); strings.Contains(names, "sing-box") {
+ serviceName = "sing-box"
+ }
+ }
+ if state == "" {
+ if status, _ := entry["Status"].(string); strings.HasPrefix(strings.ToLower(status), "up") {
+ state = "running"
+ }
+ }
+ if serviceName == "" || state == "" {
+ continue
+ }
+ metadata["docker_"+serviceName] = state
+ switch serviceName {
+ case "sing-box":
+ markServicesByTypes(services, []string{"vless", "vless-reality", "shadowsocks", "socks", "socks5", "vmess", "hysteria2"}, state)
+ case "caddy":
+ markServicesByTypes(services, []string{"vless", "vmess"}, state)
+ }
+ }
+ return services, metadata
+}
+
+func allServicesRunning(services []ServiceStatus) bool {
+ if len(services) == 0 {
+ return false
+ }
+ for _, service := range services {
+ if service.Status != "running" {
+ return false
+ }
+ }
+ return true
+}
+
+func markServicesByTypes(services []ServiceStatus, kinds []string, state string) {
+ set := make(map[string]struct{}, len(kinds))
+ for _, kind := range kinds {
+ set[kind] = struct{}{}
+ }
+ for idx := range services {
+ if _, ok := set[services[idx].Type]; ok {
+ services[idx].Status = state
+ }
+ }
+}
+
+func jsonUnmarshalLine(line string, out *map[string]any) error {
+ decoder := strings.NewReader(line)
+ return json.NewDecoder(decoder).Decode(out)
+}
+
+func publicHost(node Node) string {
+ if strings.TrimSpace(node.Domain) != "" {
+ return node.Domain
+ }
+ return node.Host
+}
+
+func shellQuoteValue(value string) string {
+ value = strings.ReplaceAll(value, "\n", "")
+ return value
+}
+
+func buildRuntimeBundle(node Node, releaseID string) (string, string, error) {
+ rootDir, err := os.MkdirTemp("", "vpnem-node-bundle-*")
+ if err != nil {
+ return "", "", err
+ }
+ bundleDir := filepath.Join(rootDir, "bundle")
+ if err := RenderRuntimeBundle(bundleDir, node, releaseID); err != nil {
+ os.RemoveAll(rootDir)
+ return "", "", err
+ }
+ tarballPath := filepath.Join(rootDir, "bundle.tar.gz")
+ if err := CreateTarGzFromDir(bundleDir, tarballPath); err != nil {
+ os.RemoveAll(rootDir)
+ return "", "", err
+ }
+ return rootDir, tarballPath, nil
+}
+
+func buildReleaseID(now time.Time) string {
+ return now.UTC().Format("20060102-150405")
+}
diff --git a/internal/control/bootstrap_test.go b/internal/control/bootstrap_test.go
new file mode 100644
index 0000000..70e5ccb
--- /dev/null
+++ b/internal/control/bootstrap_test.go
@@ -0,0 +1,58 @@
+package control
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRenderBootstrapScript(t *testing.T) {
+ t.Parallel()
+
+ script := RenderBootstrapPrepareScript()
+ script += RenderBootstrapFinalizeScript(Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ }, "20260401-123000", "/tmp/vpnem-node-nl-01.tar.gz")
+
+ if !strings.Contains(script, "mkdir -p /opt/vpnem-node/releases") {
+ t.Fatal("expected remote workdir creation")
+ }
+ if !strings.Contains(script, "vpnem-node release 20260401-123000 ready for nl-01") {
+ t.Fatal("expected release finalize message")
+ }
+}
+
+func TestSaveNodeState(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ err := SaveNodeState(dir, NodeState{
+ NodeID: "nl-01",
+ BootstrapStatus: "ready",
+ Services: []ServiceStatus{
+ {Type: "vless", Status: "configured", Port: 443},
+ },
+ })
+ if err != nil {
+ t.Fatalf("SaveNodeState error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "nl-01.json"))
+ if err != nil {
+ t.Fatalf("ReadFile error = %v", err)
+ }
+ if !strings.Contains(string(data), `"bootstrap_status": "ready"`) {
+ t.Fatal("expected bootstrap_status in state file")
+ }
+}
diff --git a/internal/control/catalog.go b/internal/control/catalog.go
new file mode 100644
index 0000000..9ef3c35
--- /dev/null
+++ b/internal/control/catalog.go
@@ -0,0 +1,229 @@
+package control
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "vpnem/internal/models"
+)
+
+func BuildLegacyCatalog(nodes []Node) (*models.ServersResponse, error) {
+ servers := make([]models.Server, 0)
+
+ for _, node := range nodes {
+ if !node.Enabled {
+ continue
+ }
+
+ publicHost := node.Host
+ if strings.TrimSpace(node.Domain) != "" {
+ publicHost = node.Domain
+ }
+
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ if err := ensureRealityProfile(&protocol); err != nil {
+ return nil, err
+ }
+
+ server, err := legacyServerFromNode(node, publicHost, protocol)
+ if err != nil {
+ return nil, err
+ }
+ servers = append(servers, server)
+ }
+ }
+
+ sort.Slice(servers, func(i, j int) bool {
+ return servers[i].Tag < servers[j].Tag
+ })
+
+ return &models.ServersResponse{Servers: servers}, nil
+}
+
+func WriteLegacyCatalog(path string, nodes []Node) error {
+ resp, err := BuildLegacyCatalog(nodes)
+ if err != nil {
+ return err
+ }
+ staticResp, err := LoadStaticLegacyCatalog(filepath.Join(filepath.Dir(path), "static-servers.json"))
+ if err != nil {
+ return err
+ }
+ resp.Servers = MergeLegacyServers(staticResp.Servers, resp.Servers)
+
+ data, err := json.MarshalIndent(resp, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ tmpPath := path + ".tmp"
+ if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, path)
+}
+
+func LoadStaticLegacyCatalog(path string) (*models.ServersResponse, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return &models.ServersResponse{Servers: nil}, nil
+ }
+ return nil, err
+ }
+
+ var resp models.ServersResponse
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+func MergeLegacyServers(primary, secondary []models.Server) []models.Server {
+ merged := make([]models.Server, 0, len(primary)+len(secondary))
+ seen := make(map[string]struct{}, len(primary)+len(secondary))
+ for _, item := range primary {
+ if _, ok := seen[item.Tag]; ok {
+ continue
+ }
+ seen[item.Tag] = struct{}{}
+ merged = append(merged, item)
+ }
+ for _, item := range secondary {
+ if _, ok := seen[item.Tag]; ok {
+ continue
+ }
+ seen[item.Tag] = struct{}{}
+ merged = append(merged, item)
+ }
+ sort.Slice(merged, func(i, j int) bool {
+ return merged[i].Tag < merged[j].Tag
+ })
+ return merged
+}
+
+func legacyServerFromNode(node Node, publicHost string, protocol ProtocolProfile) (models.Server, error) {
+ switch protocol.Type {
+ case "socks", "socks5":
+ return models.Server{
+ Tag: node.ID + "-socks5",
+ Region: node.Region,
+ Type: "socks",
+ Server: publicHost,
+ ServerPort: protocol.Port,
+ }, nil
+ case "vless":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return models.Server{}, fmt.Errorf("node %s protocol vless requires auth.uuid", node.ID)
+ }
+ server := models.Server{
+ Tag: node.ID + "-vless",
+ Region: node.Region,
+ Type: "vless",
+ Server: publicHost,
+ ServerPort: protocol.Port,
+ UUID: protocol.Auth.UUID,
+ }
+ if protocol.TLS != nil {
+ server.TLS = &models.TLS{
+ Enabled: protocol.TLS.Enabled,
+ ServerName: protocol.TLS.ServerName,
+ Insecure: false,
+ }
+ }
+ if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" {
+ server.Transport = &models.Transport{
+ Type: transportType,
+ Path: stringFromExtra(protocol.Extra, "path"),
+ }
+ }
+ return server, nil
+ case "vless-reality":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return models.Server{}, fmt.Errorf("node %s protocol vless-reality requires auth.uuid", node.ID)
+ }
+ if protocol.Reality == nil {
+ return models.Server{}, fmt.Errorf("node %s protocol vless-reality requires reality settings", node.ID)
+ }
+ server := models.Server{
+ Tag: node.ID + "-vless-reality",
+ Region: node.Region,
+ Type: "vless-reality",
+ Server: publicHost,
+ ServerPort: protocol.Port,
+ UUID: protocol.Auth.UUID,
+ TLS: &models.TLS{
+ Enabled: true,
+ ServerName: protocol.Reality.ServerName,
+ Reality: &models.Reality{
+ Enabled: true,
+ PublicKey: protocol.Reality.PublicKey,
+ ShortID: protocol.Reality.ShortID,
+ Fingerprint: protocol.Reality.Fingerprint,
+ },
+ },
+ }
+ return server, nil
+ case "shadowsocks":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return models.Server{}, fmt.Errorf("node %s protocol shadowsocks requires auth.method and auth.password", node.ID)
+ }
+ return models.Server{
+ Tag: node.ID + "-shadowsocks",
+ Region: node.Region,
+ Type: "shadowsocks",
+ Server: publicHost,
+ ServerPort: protocol.Port,
+ Method: protocol.Auth.Method,
+ Password: protocol.Auth.Password,
+ }, nil
+ case "hysteria2":
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return models.Server{}, fmt.Errorf("node %s protocol hysteria2 requires auth.password", node.ID)
+ }
+ server := models.Server{
+ Tag: node.ID + "-hysteria2",
+ Region: node.Region,
+ Type: "hysteria2",
+ Server: publicHost,
+ ServerPort: protocol.Port,
+ Password: protocol.Auth.Password,
+ ObfsPassword: stringFromExtra(protocol.Extra, "obfs_password"),
+ UpMbps: intFromExtra(protocol.Extra, "up_mbps", 0),
+ DownMbps: intFromExtra(protocol.Extra, "down_mbps", 0),
+ TLS: &models.TLS{
+ Enabled: true,
+ Insecure: true,
+ ServerName: "",
+ ALPN: []string{defaultHysteria2ALPN},
+ MinVersion: "1.3",
+ MaxVersion: "1.3",
+ },
+ }
+ if protocol.TLS != nil && protocol.TLS.ServerName != "" {
+ server.TLS.ServerName = protocol.TLS.ServerName
+ }
+ return server, nil
+ default:
+ return models.Server{}, fmt.Errorf("node %s uses unsupported legacy protocol %q", node.ID, protocol.Type)
+ }
+}
+
+func stringFromExtra(extra map[string]any, key string) string {
+ if extra == nil {
+ return ""
+ }
+ value, _ := extra[key].(string)
+ return value
+}
diff --git a/internal/control/catalog_test.go b/internal/control/catalog_test.go
new file mode 100644
index 0000000..facaaf7
--- /dev/null
+++ b/internal/control/catalog_test.go
@@ -0,0 +1,332 @@
+package control
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "vpnem/internal/models"
+)
+
+func TestBuildLegacyCatalog(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &TLSProfile{
+ Enabled: true,
+ ServerName: "nl-01.example.com",
+ },
+ Auth: &AuthProfile{
+ UUID: "11111111-1111-1111-1111-111111111111",
+ },
+ Extra: map[string]any{
+ "transport_type": "ws",
+ "path": "/ws",
+ },
+ },
+ {
+ Type: "shadowsocks",
+ Enabled: true,
+ Port: 8443,
+ Auth: &AuthProfile{
+ Method: "2022-blake3-aes-128-gcm",
+ Password: "secret",
+ },
+ },
+ },
+ },
+ }
+
+ resp, err := BuildLegacyCatalog(nodes)
+ if err != nil {
+ t.Fatalf("BuildLegacyCatalog error = %v", err)
+ }
+ if len(resp.Servers) != 2 {
+ t.Fatalf("len(resp.Servers) = %d, want 2", len(resp.Servers))
+ }
+ if resp.Servers[0].Tag != "nl-01-shadowsocks" {
+ t.Fatalf("unexpected first tag %q", resp.Servers[0].Tag)
+ }
+ if resp.Servers[1].Tag != "nl-01-vless" {
+ t.Fatalf("unexpected second tag %q", resp.Servers[1].Tag)
+ }
+ if resp.Servers[1].Transport == nil || resp.Servers[1].Transport.Type != "ws" {
+ t.Fatalf("expected ws transport, got %+v", resp.Servers[1].Transport)
+ }
+}
+
+func TestBuildLegacyCatalogRejectsUnsupportedProtocol(t *testing.T) {
+ t.Parallel()
+
+ _, err := BuildLegacyCatalog([]Node{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {Type: "hysteria2", Enabled: true, Port: 443},
+ },
+ },
+ })
+ if err == nil {
+ t.Fatal("expected unsupported protocol error")
+ }
+}
+
+func TestPublishableNodes(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {ID: "healthy", Name: "healthy", Region: "nl", Host: "1.1.1.1", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ {ID: "failed", Name: "failed", Region: "nl", Host: "1.1.1.2", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ {ID: "nostate", Name: "nostate", Region: "nl", Host: "1.1.1.3", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ }
+ states := map[string]*NodeState{
+ "healthy": {NodeID: "healthy", BootstrapStatus: "healthy"},
+ "failed": {NodeID: "failed", BootstrapStatus: "failed"},
+ }
+
+ got := PublishableNodes(nodes, states)
+ if len(got) != 1 {
+ t.Fatalf("len(PublishableNodes) = %d, want 1", len(got))
+ }
+ if got[0].ID != "healthy" {
+ t.Fatalf("expected healthy node, got %s", got[0].ID)
+ }
+}
+
+func TestPublishableNodesRequiresRunningServicesWhenKnown(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {ID: "healthy", Name: "healthy", Region: "nl", Host: "1.1.1.1", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ {ID: "degraded", Name: "degraded", Region: "nl", Host: "1.1.1.2", Enabled: true, SSH: SSHConfig{User: "root", Port: 22, Auth: "key"}, Protocols: []ProtocolProfile{{Type: "socks5", Enabled: true, Port: 1080}}},
+ }
+ states := map[string]*NodeState{
+ "healthy": {
+ NodeID: "healthy",
+ BootstrapStatus: "healthy",
+ Services: []ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}},
+ Metadata: map[string]any{"healthz_http_code": 200},
+ },
+ "degraded": {
+ NodeID: "degraded",
+ BootstrapStatus: "healthy",
+ Services: []ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}},
+ Metadata: map[string]any{"healthz_http_code": 503},
+ },
+ }
+
+ got := PublishableNodes(nodes, states)
+ if len(got) != 1 {
+ t.Fatalf("len(PublishableNodes) = %d, want 1", len(got))
+ }
+ if got[0].ID != "healthy" {
+ t.Fatalf("expected healthy node, got %s", got[0].ID)
+ }
+}
+
+func TestPublishDecisionForNode(t *testing.T) {
+ t.Parallel()
+
+ node := Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443},
+ },
+ }
+
+ blocked := PublishDecisionForNode(node, &NodeState{
+ NodeID: "nl-01",
+ BootstrapStatus: "healthy",
+ Services: []ServiceStatus{{Type: "vless", Status: "configured", Port: 443}},
+ Metadata: map[string]any{"healthz_http_code": 503},
+ })
+ if blocked.Eligible {
+ t.Fatal("expected blocked publish decision")
+ }
+ if len(blocked.Reasons) == 0 {
+ t.Fatal("expected reasons for blocked decision")
+ }
+
+ ready := PublishDecisionForNode(node, &NodeState{
+ NodeID: "nl-01",
+ BootstrapStatus: "healthy",
+ PublicHost: "nl-01.example.com",
+ Services: []ServiceStatus{{Type: "vless", Status: "running", Port: 443}},
+ Metadata: map[string]any{"healthz_http_code": 200},
+ })
+ if !ready.Eligible {
+ t.Fatalf("expected ready decision, got reasons: %v", ready.Reasons)
+ }
+ if ready.PublicHost != "nl-01.example.com" {
+ t.Fatalf("unexpected public host %q", ready.PublicHost)
+ }
+}
+
+func TestBuildCatalogV2(t *testing.T) {
+ t.Parallel()
+
+ nodes := []Node{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Provider: "custom-vps",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}},
+ {Type: "hysteria2", Enabled: true, Port: 9443, Auth: &AuthProfile{Password: "hidden"}, Extra: map[string]any{"obfs_password": "masked"}},
+ },
+ },
+ }
+ states := map[string]*NodeState{
+ "nl-01": {NodeID: "nl-01", BootstrapStatus: "healthy", PublicHost: "nl-01.example.com", Metadata: map[string]any{"healthz_http_code": 200}},
+ }
+
+ catalog := BuildCatalogV2(nodes, states)
+ if catalog.Version != "2" {
+ t.Fatalf("catalog.Version = %q, want 2", catalog.Version)
+ }
+ if len(catalog.Nodes) != 1 {
+ t.Fatalf("len(catalog.Nodes) = %d, want 1", len(catalog.Nodes))
+ }
+ if catalog.Nodes[0].PublicHost != "nl-01.example.com" {
+ t.Fatalf("unexpected public host %q", catalog.Nodes[0].PublicHost)
+ }
+ if len(catalog.Nodes[0].Protocols) != 2 {
+ t.Fatalf("expected 2 protocols, got %d", len(catalog.Nodes[0].Protocols))
+ }
+ if catalog.Nodes[0].Protocols[0].Type != "vless" {
+ t.Fatalf("unexpected first protocol %q", catalog.Nodes[0].Protocols[0].Type)
+ }
+}
+
+func TestMergeLegacyServersPreservesStaticEntries(t *testing.T) {
+ t.Parallel()
+
+ static := []models.Server{
+ {Tag: "nl-1", Type: "socks", Server: "1.1.1.1", ServerPort: 1080},
+ {Tag: "nl-ss-1", Type: "shadowsocks", Server: "ss.example.com", ServerPort: 443},
+ }
+ dynamic := []models.Server{
+ {Tag: "node-1-vless", Type: "vless", Server: "2.2.2.2", ServerPort: 443},
+ }
+
+ merged := MergeLegacyServers(static, dynamic)
+ if len(merged) != 3 {
+ t.Fatalf("len(merged) = %d, want 3", len(merged))
+ }
+}
+
+func TestWriteLegacyCatalogMergesStaticServers(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ staticPath := filepath.Join(dir, "static-servers.json")
+ if err := os.WriteFile(staticPath, []byte(`{"servers":[{"tag":"nl-1","region":"NL","type":"socks","server":"1.1.1.1","server_port":1080}]}`), 0o644); err != nil {
+ t.Fatalf("write static servers: %v", err)
+ }
+
+ err := WriteLegacyCatalog(filepath.Join(dir, "servers.json"), []Node{
+ {
+ ID: "node-1",
+ Name: "Node 1",
+ Region: "nl",
+ Host: "2.2.2.2",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "socks5", Enabled: true, Port: 1081},
+ },
+ },
+ })
+ if err != nil {
+ t.Fatalf("WriteLegacyCatalog error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "servers.json"))
+ if err != nil {
+ t.Fatalf("read merged servers: %v", err)
+ }
+ text := string(data)
+ if !strings.Contains(text, `"tag": "nl-1"`) {
+ t.Fatalf("expected static server in merged catalog: %s", text)
+ }
+ if !strings.Contains(text, `"tag": "node-1-socks5"`) {
+ t.Fatalf("expected dynamic server in merged catalog: %s", text)
+ }
+}
+
+func TestWriteCatalogV2DoesNotMergeStaticLegacyServers(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ staticPath := filepath.Join(dir, "static-servers.json")
+ if err := os.WriteFile(staticPath, []byte(`{"servers":[{"tag":"nl-ss-1","region":"NL","type":"shadowsocks","server":"ss.example.com","server_port":443,"method":"chacha20-ietf-poly1305","password":"secret"}]}`), 0o644); err != nil {
+ t.Fatalf("write static servers: %v", err)
+ }
+
+ err := WriteCatalogV2(filepath.Join(dir, "catalog-v2.json"), []Node{
+ {
+ ID: "node-1",
+ Name: "Node 1",
+ Region: "nl",
+ Host: "2.2.2.2",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}},
+ },
+ },
+ }, map[string]*NodeState{})
+ if err != nil {
+ t.Fatalf("WriteCatalogV2 error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "catalog-v2.json"))
+ if err != nil {
+ t.Fatalf("read catalog v2: %v", err)
+ }
+ text := string(data)
+ if strings.Contains(text, `"id": "nl-ss-1"`) {
+ t.Fatalf("did not expect static legacy node in catalog v2: %s", text)
+ }
+ if !strings.Contains(text, `"id": "node-1"`) {
+ t.Fatalf("expected dynamic node in catalog v2: %s", text)
+ }
+}
diff --git a/internal/control/dns.go b/internal/control/dns.go
new file mode 100644
index 0000000..f841d91
--- /dev/null
+++ b/internal/control/dns.go
@@ -0,0 +1,163 @@
+package control
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+)
+
+const porkbunAPIHost = "https://api.porkbun.com/api/json/v3"
+
+var porkbunAPIHostOverride string
+
+type DNSProvider interface {
+ EnsureRandomARecord(ctx context.Context, zone, prefix, ip string, ttl int) (string, error)
+ DeleteARecord(ctx context.Context, zone, name string) error
+}
+
+type PorkbunClient struct {
+ APIKey string
+ SecretAPIKey string
+ HTTPClient *http.Client
+}
+
+type porkbunResponse struct {
+ Status string `json:"status"`
+ Message string `json:"message"`
+ Records []map[string]any `json:"records"`
+ ID string `json:"id"`
+}
+
+func (c PorkbunClient) EnsureRandomARecord(ctx context.Context, zone, prefix, ip string, ttl int) (string, error) {
+ if err := c.validate(); err != nil {
+ return "", err
+ }
+ if ttl == 0 {
+ ttl = 600
+ }
+
+ for range 10 {
+ name := randomSubdomain(prefix)
+ records, err := c.retrieveRecordsByNameType(ctx, zone, "A", name)
+ if err != nil {
+ return "", err
+ }
+ if len(records) > 0 {
+ continue
+ }
+ if err := c.createRecord(ctx, zone, name, "A", ip, ttl); err != nil {
+ return "", err
+ }
+ return name + "." + zone, nil
+ }
+
+ return "", errors.New("failed to allocate unique subdomain")
+}
+
+func (c PorkbunClient) DeleteARecord(ctx context.Context, zone, name string) error {
+ if err := c.validate(); err != nil {
+ return err
+ }
+ return c.deleteByNameType(ctx, zone, "A", name)
+}
+
+func (c PorkbunClient) validate() error {
+ if strings.TrimSpace(c.APIKey) == "" || strings.TrimSpace(c.SecretAPIKey) == "" {
+ return errors.New("porkbun api keys are not configured")
+ }
+ return nil
+}
+
+func (c PorkbunClient) createRecord(ctx context.Context, zone, name, recordType, content string, ttl int) error {
+ payload := map[string]string{
+ "secretapikey": c.SecretAPIKey,
+ "apikey": c.APIKey,
+ "name": name,
+ "type": recordType,
+ "content": content,
+ "ttl": fmt.Sprintf("%d", ttl),
+ }
+ _, err := c.post(ctx, "/dns/create/"+zone, payload)
+ return err
+}
+
+func (c PorkbunClient) deleteByNameType(ctx context.Context, zone, recordType, name string) error {
+ payload := map[string]string{
+ "secretapikey": c.SecretAPIKey,
+ "apikey": c.APIKey,
+ }
+ _, err := c.post(ctx, "/dns/deleteByNameType/"+zone+"/"+recordType+"/"+name, payload)
+ return err
+}
+
+func (c PorkbunClient) retrieveRecordsByNameType(ctx context.Context, zone, recordType, name string) ([]map[string]any, error) {
+ payload := map[string]string{
+ "secretapikey": c.SecretAPIKey,
+ "apikey": c.APIKey,
+ }
+ resp, err := c.post(ctx, "/dns/retrieveByNameType/"+zone+"/"+recordType+"/"+name, payload)
+ if err != nil {
+ return nil, err
+ }
+ return resp.Records, nil
+}
+
+func (c PorkbunClient) post(ctx context.Context, path string, payload map[string]string) (*porkbunResponse, error) {
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return nil, err
+ }
+
+ client := c.HTTPClient
+ if client == nil {
+ client = &http.Client{Timeout: 15 * time.Second}
+ }
+
+ baseURL := porkbunAPIHost
+ if porkbunAPIHostOverride != "" {
+ baseURL = porkbunAPIHostOverride
+ }
+
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+path, bytes.NewReader(data))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ var out porkbunResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("porkbun http %d: %s", resp.StatusCode, out.Message)
+ }
+ if strings.ToUpper(out.Status) != "SUCCESS" {
+ return nil, fmt.Errorf("porkbun api error: %s", out.Message)
+ }
+ return &out, nil
+}
+
+func randomSubdomain(prefix string) string {
+ if prefix == "" {
+ prefix = "vpn"
+ }
+ var buf [4]byte
+ if _, err := rand.Read(buf[:]); err != nil {
+ now := time.Now().UTC().UnixNano()
+ return fmt.Sprintf("%s-%x", prefix, now)
+ }
+ return prefix + "-" + hex.EncodeToString(buf[:])
+}
diff --git a/internal/control/dns_test.go b/internal/control/dns_test.go
new file mode 100644
index 0000000..cf44639
--- /dev/null
+++ b/internal/control/dns_test.go
@@ -0,0 +1,58 @@
+package control
+
+import (
+ "context"
+ "encoding/json"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func TestPorkbunEnsureRandomARecord(t *testing.T) {
+ t.Parallel()
+
+ retrieveCalls := 0
+ client := &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
+ recorder := map[string]any{}
+ switch {
+ case strings.Contains(r.URL.Path, "/dns/retrieveByNameType/"):
+ retrieveCalls++
+ recorder = map[string]any{
+ "status": "SUCCESS",
+ "records": []map[string]any{},
+ }
+ case strings.Contains(r.URL.Path, "/dns/create/"):
+ recorder = map[string]any{
+ "status": "SUCCESS",
+ "id": "123",
+ }
+ default:
+ t.Fatalf("unexpected path %s", r.URL.Path)
+ }
+ body, _ := json.Marshal(recorder)
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: make(http.Header),
+ Body: io.NopCloser(strings.NewReader(string(body))),
+ }, nil
+ })}
+
+ clientAPI := PorkbunClient{APIKey: "a", SecretAPIKey: "b", HTTPClient: client}
+ name, err := clientAPI.EnsureRandomARecord(context.Background(), "em-sysadmin.xyz", "vpn", "203.0.113.10", 600)
+ if err != nil {
+ t.Fatalf("EnsureRandomARecord error = %v", err)
+ }
+ if !strings.HasSuffix(name, ".em-sysadmin.xyz") {
+ t.Fatalf("expected fqdn suffix, got %q", name)
+ }
+ if retrieveCalls == 0 {
+ t.Fatal("expected retrieve call")
+ }
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) {
+ return f(r)
+}
diff --git a/internal/control/health_test.go b/internal/control/health_test.go
new file mode 100644
index 0000000..a0f488f
--- /dev/null
+++ b/internal/control/health_test.go
@@ -0,0 +1,49 @@
+package control
+
+import "testing"
+
+func TestParseHealthCheckOutput(t *testing.T) {
+ t.Parallel()
+
+ stdout := `{"Service":"sing-box","State":"running"}
+{"Service":"caddy","State":"running"}
+HEALTHZ_HTTP_CODE=200
+`
+ services, metadata := parseHealthCheckOutput(stdout, []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443},
+ {Type: "vmess", Enabled: true, Port: 443},
+ {Type: "shadowsocks", Enabled: true, Port: 8443},
+ })
+
+ if len(services) != 3 {
+ t.Fatalf("len(services) = %d, want 3", len(services))
+ }
+ if metadata["healthz_http_code"] != 200 {
+ t.Fatalf("healthz_http_code = %v, want 200", metadata["healthz_http_code"])
+ }
+ if services[0].Status != "running" && services[1].Status != "running" && services[2].Status != "running" {
+ t.Fatal("expected at least one service marked running")
+ }
+}
+
+func TestParseHealthCheckOutputDockerPSFallback(t *testing.T) {
+ t.Parallel()
+
+ stdout := `{"Names":"current_sing-box_1","Labels":"com.docker.compose.service=sing-box,com.docker.compose.project=current","State":"running","Status":"Up 52 seconds"}
+HY2_MIXED_PORT=5.180.97.199
+`
+ services, _ := parseHealthCheckOutput(stdout, []ProtocolProfile{
+ {Type: "vless-reality", Enabled: true, Port: 443},
+ {Type: "hysteria2", Enabled: true, Port: 443},
+ })
+
+ if len(services) != 2 {
+ t.Fatalf("len(services) = %d, want 2", len(services))
+ }
+ if services[0].Status != "running" {
+ t.Fatalf("vless-reality status = %q, want running", services[0].Status)
+ }
+ if services[1].Status != "running" {
+ t.Fatalf("hysteria2 status = %q, want running", services[1].Status)
+ }
+}
diff --git a/internal/control/hysteria2.go b/internal/control/hysteria2.go
new file mode 100644
index 0000000..78c80e7
--- /dev/null
+++ b/internal/control/hysteria2.go
@@ -0,0 +1,179 @@
+package control
+
+import (
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "crypto/x509"
+ "crypto/x509/pkix"
+ "encoding/base64"
+ "encoding/hex"
+ "encoding/pem"
+ "fmt"
+ "math/big"
+ "strings"
+ "time"
+)
+
+const (
+ defaultHysteria2Port = 443
+ defaultHysteria2UpMbps = 100
+ defaultHysteria2DownMbps = 100
+ defaultHysteria2CertPath = "/etc/sing-box/cert.pem"
+ defaultHysteria2KeyPath = "/etc/sing-box/key.pem"
+ defaultHysteria2ALPN = "h3"
+)
+
+func ensureHysteria2Profile(protocol *ProtocolProfile) error {
+ if protocol == nil || protocol.Type != "hysteria2" {
+ return nil
+ }
+ if protocol.Hysteria2 == nil {
+ protocol.Hysteria2 = &Hysteria2Profile{}
+ }
+
+ if protocol.Port > 0 && protocol.Hysteria2.Port == 0 {
+ protocol.Hysteria2.Port = protocol.Port
+ }
+ if protocol.Hysteria2.Port == 0 {
+ protocol.Hysteria2.Port = defaultHysteria2Port
+ }
+ protocol.Port = protocol.Hysteria2.Port
+
+ if protocol.Auth == nil {
+ protocol.Auth = &AuthProfile{}
+ }
+ if protocol.Hysteria2.UserPassword == "" && strings.TrimSpace(protocol.Auth.Password) != "" {
+ protocol.Hysteria2.UserPassword = strings.TrimSpace(protocol.Auth.Password)
+ }
+ if protocol.Hysteria2.UserPassword == "" {
+ password, err := randomBase64(16)
+ if err != nil {
+ return err
+ }
+ protocol.Hysteria2.UserPassword = password
+ }
+ protocol.Auth.Password = protocol.Hysteria2.UserPassword
+
+ if protocol.Extra == nil {
+ protocol.Extra = map[string]any{}
+ }
+ if protocol.Hysteria2.ObfsPassword == "" {
+ if extra := stringFromExtra(protocol.Extra, "obfs_password"); extra != "" {
+ protocol.Hysteria2.ObfsPassword = extra
+ }
+ }
+ if protocol.Hysteria2.ObfsPassword == "" {
+ obfsPassword, err := randomHex(32)
+ if err != nil {
+ return err
+ }
+ protocol.Hysteria2.ObfsPassword = obfsPassword
+ }
+ protocol.Extra["obfs_password"] = protocol.Hysteria2.ObfsPassword
+
+ if protocol.Hysteria2.UpMbps == 0 {
+ protocol.Hysteria2.UpMbps = intFromExtra(protocol.Extra, "up_mbps", defaultHysteria2UpMbps)
+ }
+ if protocol.Hysteria2.DownMbps == 0 {
+ protocol.Hysteria2.DownMbps = intFromExtra(protocol.Extra, "down_mbps", defaultHysteria2DownMbps)
+ }
+ protocol.Extra["up_mbps"] = protocol.Hysteria2.UpMbps
+ protocol.Extra["down_mbps"] = protocol.Hysteria2.DownMbps
+
+ if protocol.Hysteria2.CertPath == "" {
+ protocol.Hysteria2.CertPath = firstNonEmptyString(stringFromExtra(protocol.Extra, "tls_cert_path"), defaultHysteria2CertPath)
+ }
+ if protocol.Hysteria2.KeyPath == "" {
+ protocol.Hysteria2.KeyPath = firstNonEmptyString(stringFromExtra(protocol.Extra, "tls_key_path"), defaultHysteria2KeyPath)
+ }
+ protocol.Extra["tls_cert_path"] = protocol.Hysteria2.CertPath
+ protocol.Extra["tls_key_path"] = protocol.Hysteria2.KeyPath
+
+ if protocol.TLS == nil {
+ protocol.TLS = &TLSProfile{}
+ }
+ protocol.TLS.Enabled = true
+ if strings.TrimSpace(protocol.TLS.ServerName) == "" {
+ protocol.TLS.ServerName = ""
+ }
+ return nil
+}
+
+func GenerateSelfSignedCert() (certPEM, keyPEM []byte, err error) {
+ commonName := "node-" + randomHostnameSuffix() + ".local"
+ return generateSelfSignedCertForHost(commonName)
+}
+
+func generateSelfSignedCertForHost(commonName string) (certPEM, keyPEM []byte, err error) {
+ privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ if err != nil {
+ return nil, nil, fmt.Errorf("generate ecdsa key: %w", err)
+ }
+ serialLimit := new(big.Int).Lsh(big.NewInt(1), 128)
+ serialNumber, err := rand.Int(rand.Reader, serialLimit)
+ if err != nil {
+ return nil, nil, fmt.Errorf("generate serial: %w", err)
+ }
+
+ template := &x509.Certificate{
+ SerialNumber: serialNumber,
+ Subject: pkix.Name{
+ CommonName: commonName,
+ },
+ NotBefore: time.Now().UTC().Add(-time.Hour),
+ NotAfter: time.Now().UTC().AddDate(10, 0, 0),
+ KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
+ ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
+ BasicConstraintsValid: true,
+ DNSNames: []string{commonName},
+ }
+
+ certDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
+ if err != nil {
+ return nil, nil, fmt.Errorf("create certificate: %w", err)
+ }
+ certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
+
+ keyDER, err := x509.MarshalECPrivateKey(privateKey)
+ if err != nil {
+ return nil, nil, fmt.Errorf("marshal private key: %w", err)
+ }
+ keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
+ return certPEM, keyPEM, nil
+}
+
+func randomBase64(size int) (string, error) {
+ buf := make([]byte, size)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return base64.RawStdEncoding.EncodeToString(buf), nil
+}
+
+func randomHostnameSuffix() string {
+ buf := make([]byte, 4)
+ if _, err := rand.Read(buf); err != nil {
+ return "local"
+ }
+ return hex.EncodeToString(buf)
+}
+
+func firstNonEmptyString(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return strings.TrimSpace(value)
+ }
+ }
+ return ""
+}
+
+func EnsureProtocolForUI(protocol *ProtocolProfile) error {
+ if err := ensureRealityProfile(protocol); err != nil {
+ return err
+ }
+ if err := ensureHysteria2Profile(protocol); err != nil {
+ return err
+ }
+ return nil
+}
diff --git a/internal/control/inventory.go b/internal/control/inventory.go
new file mode 100644
index 0000000..22d9179
--- /dev/null
+++ b/internal/control/inventory.go
@@ -0,0 +1,225 @@
+package control
+
+import (
+ "errors"
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+
+ "gopkg.in/yaml.v3"
+)
+
+type Inventory struct {
+ Nodes []Node
+}
+
+func LoadInventoryDir(dir string) (*Inventory, error) {
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return nil, err
+ }
+
+ nodes := make([]Node, 0, len(entries))
+ for _, entry := range entries {
+ if entry.IsDir() {
+ continue
+ }
+ ext := strings.ToLower(filepath.Ext(entry.Name()))
+ if ext != ".yaml" && ext != ".yml" {
+ continue
+ }
+
+ node, err := LoadNodeFile(filepath.Join(dir, entry.Name()))
+ if err != nil {
+ return nil, err
+ }
+ nodes = append(nodes, *node)
+ }
+
+ sort.Slice(nodes, func(i, j int) bool {
+ return nodes[i].ID < nodes[j].ID
+ })
+
+ return &Inventory{Nodes: nodes}, nil
+}
+
+func LoadNodeFile(path string) (*Node, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var node Node
+ if err := yaml.Unmarshal(data, &node); err != nil {
+ return nil, fmt.Errorf("parse %s: %w", path, err)
+ }
+ for idx := range node.Protocols {
+ if err := ensureRealityProfile(&node.Protocols[idx]); err != nil {
+ return nil, fmt.Errorf("prepare %s: %w", path, err)
+ }
+ if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil {
+ return nil, fmt.Errorf("prepare %s: %w", path, err)
+ }
+ }
+ if err := ValidateNode(node); err != nil {
+ return nil, fmt.Errorf("validate %s: %w", path, err)
+ }
+
+ return &node, nil
+}
+
+func SaveNodeFile(dir string, node Node) (string, error) {
+ for idx := range node.Protocols {
+ if err := ensureRealityProfile(&node.Protocols[idx]); err != nil {
+ return "", err
+ }
+ if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil {
+ return "", err
+ }
+ }
+ if err := ValidateNode(node); err != nil {
+ return "", err
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+
+ path := filepath.Join(dir, node.ID+".yaml")
+ data, err := yaml.Marshal(node)
+ if err != nil {
+ return "", err
+ }
+ if err := os.WriteFile(path, data, 0o600); err != nil {
+ return "", err
+ }
+
+ return path, nil
+}
+
+func (i *Inventory) NodeByID(id string) (*Node, bool) {
+ for idx := range i.Nodes {
+ if i.Nodes[idx].ID == id {
+ return &i.Nodes[idx], true
+ }
+ }
+ return nil, false
+}
+
+func ValidateNode(node Node) error {
+ if strings.TrimSpace(node.ID) == "" {
+ return errors.New("id is required")
+ }
+ if strings.TrimSpace(node.Name) == "" {
+ return errors.New("name is required")
+ }
+ if strings.TrimSpace(node.Region) == "" {
+ return errors.New("region is required")
+ }
+ if strings.TrimSpace(node.Host) == "" {
+ return errors.New("host is required")
+ }
+ if node.SSH.Port < 0 || node.SSH.Port > 65535 {
+ return errors.New("ssh.port must be between 0 and 65535")
+ }
+ if node.SSH.Port == 0 {
+ node.SSH.Port = 22
+ }
+ if strings.TrimSpace(node.SSH.User) == "" {
+ return errors.New("ssh.user is required")
+ }
+ if strings.TrimSpace(node.SSH.Auth) == "" {
+ return errors.New("ssh.auth is required")
+ }
+ switch strings.TrimSpace(node.SSH.Auth) {
+ case "key":
+ if strings.TrimSpace(node.SSH.IdentityFile) == "" {
+ return errors.New("ssh.identity_file is required when ssh.auth=key")
+ }
+ case "password":
+ if strings.TrimSpace(node.SSH.PasswordEnv) == "" {
+ return errors.New("ssh.password_env is required when ssh.auth=password")
+ }
+ default:
+ return errors.New("ssh.auth must be either key or password")
+ }
+ if len(node.Protocols) == 0 {
+ return errors.New("at least one protocol is required")
+ }
+
+ seen := make(map[string]struct{}, len(node.Protocols))
+ for _, protocol := range node.Protocols {
+ if strings.TrimSpace(protocol.Type) == "" {
+ return errors.New("protocol.type is required")
+ }
+ if protocol.Port <= 0 || protocol.Port > 65535 {
+ return fmt.Errorf("protocol %s has invalid port", protocol.Type)
+ }
+ if protocol.Type == "vless" && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) == "" {
+ return errors.New("vless with tls.enabled requires node.domain")
+ }
+ if protocol.Type == "vless-reality" {
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" {
+ return errors.New("vless-reality requires auth.uuid")
+ }
+ if protocol.Reality == nil {
+ return errors.New("vless-reality requires reality settings")
+ }
+ if strings.TrimSpace(protocol.Reality.ServerName) == "" {
+ return errors.New("vless-reality requires reality.server_name")
+ }
+ if strings.TrimSpace(protocol.Reality.PrivateKey) == "" {
+ return errors.New("vless-reality requires reality.private_key")
+ }
+ if strings.TrimSpace(protocol.Reality.PublicKey) == "" {
+ return errors.New("vless-reality requires reality.public_key")
+ }
+ if strings.TrimSpace(protocol.Reality.ShortID) == "" {
+ return errors.New("vless-reality requires reality.short_id")
+ }
+ }
+ if protocol.Type == "vmess" && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) == "" {
+ return errors.New("vmess with tls.enabled requires node.domain")
+ }
+ if protocol.Type == "hysteria2" {
+ if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" {
+ return errors.New("hysteria2 requires auth.password")
+ }
+ if protocol.Hysteria2 == nil {
+ return errors.New("hysteria2 requires hysteria2 settings")
+ }
+ if protocol.Hysteria2.CertPath == "" || protocol.Hysteria2.KeyPath == "" {
+ return errors.New("hysteria2 requires cert_path and key_path")
+ }
+ }
+ key := protocol.Type
+ if _, ok := seen[key]; ok {
+ return fmt.Errorf("duplicate protocol %s", protocol.Type)
+ }
+ seen[key] = struct{}{}
+ }
+
+ return nil
+}
+
+func CopyNodeFile(srcPath, inventoryDir string) (string, error) {
+ node, err := LoadNodeFile(srcPath)
+ if err != nil {
+ return "", err
+ }
+ return SaveNodeFile(inventoryDir, *node)
+}
+
+func DeleteNodeFile(dir, nodeID string) error {
+ err := os.Remove(filepath.Join(dir, nodeID+".yaml"))
+ if errors.Is(err, fs.ErrNotExist) {
+ return nil
+ }
+ return err
+}
+
+func IsNotExist(err error) bool {
+ return errors.Is(err, fs.ErrNotExist)
+}
diff --git a/internal/control/inventory_test.go b/internal/control/inventory_test.go
new file mode 100644
index 0000000..eb03979
--- /dev/null
+++ b/internal/control/inventory_test.go
@@ -0,0 +1,50 @@
+package control
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestLoadInventoryDir(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ input := `id: nl-01
+name: NL 01
+provider: custom-vps
+region: nl
+host: 203.0.113.10
+domain: nl-01.example.com
+acme_email: admin@example.com
+enabled: true
+ssh:
+ user: root
+ port: 22
+ auth: key
+ identity_file: ~/.ssh/id_ed25519
+protocols:
+ - type: vless
+ enabled: true
+ port: 443
+ tls:
+ enabled: true
+ server_name: nl-01.example.com
+ auth:
+ uuid: 11111111-1111-1111-1111-111111111111
+`
+ if err := os.WriteFile(filepath.Join(dir, "nl-01.yaml"), []byte(input), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ inventory, err := LoadInventoryDir(dir)
+ if err != nil {
+ t.Fatalf("LoadInventoryDir error = %v", err)
+ }
+ if len(inventory.Nodes) != 1 {
+ t.Fatalf("len(inventory.Nodes) = %d, want 1", len(inventory.Nodes))
+ }
+ if inventory.Nodes[0].ID != "nl-01" {
+ t.Fatalf("inventory.Nodes[0].ID = %q, want nl-01", inventory.Nodes[0].ID)
+ }
+}
diff --git a/internal/control/lifecycle.go b/internal/control/lifecycle.go
new file mode 100644
index 0000000..a45339f
--- /dev/null
+++ b/internal/control/lifecycle.go
@@ -0,0 +1,205 @@
+package control
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "fmt"
+ "strings"
+)
+
+func SetNodeEnabled(node Node, enabled bool) Node {
+ node.Enabled = enabled
+ return node
+}
+
+func RotateNodeSecrets(node Node) (Node, error) {
+ for idx := range node.Protocols {
+ protocol := &node.Protocols[idx]
+ switch protocol.Type {
+ case "vless", "vmess":
+ if protocol.Auth == nil {
+ protocol.Auth = &AuthProfile{}
+ }
+ uuid, err := randomUUID()
+ if err != nil {
+ return node, err
+ }
+ protocol.Auth.UUID = uuid
+ case "shadowsocks":
+ if protocol.Auth == nil {
+ protocol.Auth = &AuthProfile{}
+ }
+ password, err := randomHex(16)
+ if err != nil {
+ return node, err
+ }
+ protocol.Auth.Password = password
+ case "hysteria2":
+ if err := ensureHysteria2Profile(protocol); err != nil {
+ return node, err
+ }
+ password, err := randomBase64(16)
+ if err != nil {
+ return node, err
+ }
+ protocol.Auth.Password = password
+ protocol.Hysteria2.UserPassword = password
+ obfsPassword, err := randomHex(32)
+ if err != nil {
+ return node, err
+ }
+ protocol.Hysteria2.ObfsPassword = obfsPassword
+ if protocol.Extra == nil {
+ protocol.Extra = map[string]any{}
+ }
+ protocol.Extra["obfs_password"] = obfsPassword
+ }
+ }
+ return node, nil
+}
+
+func AddSocks5Protocol(node Node, port int) (Node, error) {
+ if port <= 0 {
+ port = 54101
+ }
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "socks5" || protocol.Type == "socks" {
+ return node, fmt.Errorf("node %s already has SOCKS5 enabled", node.ID)
+ }
+ }
+ node.Protocols = append(node.Protocols, ProtocolProfile{
+ Type: "socks5",
+ Enabled: true,
+ Port: port,
+ })
+ return node, nil
+}
+
+func DestroyNode(ctx context.Context, runner SSHExecutor, dnsClient DNSProvider, zone string, node Node, inventoryDir, stateDir string) []string {
+ var warnings []string
+
+ if dnsClient != nil && strings.TrimSpace(node.Domain) != "" && strings.HasSuffix(node.Domain, "."+zone) {
+ name := strings.TrimSuffix(node.Domain, "."+zone)
+ name = strings.TrimSuffix(name, ".")
+ if err := dnsClient.DeleteARecord(ctx, zone, name); err != nil {
+ warnings = append(warnings, "dns cleanup failed: "+err.Error())
+ }
+ }
+
+ if strings.TrimSpace(node.Host) != "" {
+ if _, err := runner.Run(ctx, node, RenderDestroyScript()); err != nil {
+ warnings = append(warnings, "remote cleanup failed: "+err.Error())
+ }
+ }
+
+ if err := DeleteNodeState(stateDir, node.ID); err != nil {
+ warnings = append(warnings, "state cleanup failed: "+err.Error())
+ }
+ if err := DeleteNodeFile(inventoryDir, node.ID); err != nil {
+ warnings = append(warnings, "inventory cleanup failed: "+err.Error())
+ }
+
+ return warnings
+}
+
+func UpgradeNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) {
+ if _, err := BootstrapNode(ctx, runner, node, BootstrapOptions{
+ StateDir: stateDir,
+ DryRun: false,
+ }); err != nil {
+ return nil, err
+ }
+
+ state, err := CheckNode(ctx, runner, node, stateDir)
+ if state != nil {
+ if state.Metadata == nil {
+ state.Metadata = map[string]any{}
+ }
+ state.Metadata["lifecycle_action"] = "upgrade"
+ _ = SaveNodeState(stateDir, *state)
+ }
+ return state, err
+}
+
+func RepairReinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) {
+ return reinstallNode(ctx, runner, node, stateDir, "repair_reinstall")
+}
+
+func CleanReinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir string) (*NodeState, error) {
+ return reinstallNode(ctx, runner, node, stateDir, "clean_reinstall")
+}
+
+func reinstallNode(ctx context.Context, runner SSHExecutor, node Node, stateDir, action string) (*NodeState, error) {
+ cleanupWarning := ""
+ if strings.TrimSpace(node.Host) != "" {
+ if _, err := runner.Run(ctx, node, RenderDestroyScript()); err != nil {
+ cleanupWarning = err.Error()
+ }
+ }
+
+ if _, err := BootstrapNode(ctx, runner, node, BootstrapOptions{
+ StateDir: stateDir,
+ DryRun: false,
+ }); err != nil {
+ return nil, err
+ }
+
+ state, err := CheckNode(ctx, runner, node, stateDir)
+ if state != nil {
+ if state.Metadata == nil {
+ state.Metadata = map[string]any{}
+ }
+ state.Metadata["lifecycle_action"] = action
+ if cleanupWarning != "" {
+ state.Metadata["cleanup_warning"] = cleanupWarning
+ }
+ _ = SaveNodeState(stateDir, *state)
+ }
+ return state, err
+}
+
+func RenderDestroyScript() string {
+ return `set -eu
+if [ -f /opt/vpnem-node/current/docker-compose.yml ]; then
+ if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
+ docker compose -f /opt/vpnem-node/current/docker-compose.yml down -v || true
+ elif command -v docker-compose >/dev/null 2>&1; then
+ docker-compose -f /opt/vpnem-node/current/docker-compose.yml down -v || true
+ fi
+fi
+rm -rf /opt/vpnem-node
+printf 'vpnem-node removed\n'
+`
+}
+
+func randomHex(size int) (string, error) {
+ buf := make([]byte, size)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(buf), nil
+}
+
+func randomUUID() (string, error) {
+ buf := make([]byte, 16)
+ if _, err := rand.Read(buf); err != nil {
+ return "", err
+ }
+ buf[6] = (buf[6] & 0x0f) | 0x40
+ buf[8] = (buf[8] & 0x3f) | 0x80
+ hexID := hex.EncodeToString(buf)
+ return fmt.Sprintf("%s-%s-%s-%s-%s",
+ hexID[0:8],
+ hexID[8:12],
+ hexID[12:16],
+ hexID[16:20],
+ hexID[20:32],
+ ), nil
+}
+
+func RandomHexForAPI(size int) (string, error) { return randomHex(size) }
+
+func RandomBase64ForAPI(size int) (string, error) { return randomBase64(size) }
+
+func RandomUUIDForAPI() (string, error) { return randomUUID() }
diff --git a/internal/control/lifecycle_test.go b/internal/control/lifecycle_test.go
new file mode 100644
index 0000000..2d9958c
--- /dev/null
+++ b/internal/control/lifecycle_test.go
@@ -0,0 +1,149 @@
+package control
+
+import (
+ "context"
+ "testing"
+)
+
+func TestSetNodeEnabled(t *testing.T) {
+ t.Parallel()
+
+ node := Node{ID: "nl-01", Enabled: true}
+ disabled := SetNodeEnabled(node, false)
+ if disabled.Enabled {
+ t.Fatal("expected node to be disabled")
+ }
+ if node.Enabled != true {
+ t.Fatal("expected original node to stay unchanged")
+ }
+}
+
+func TestRotateNodeSecrets(t *testing.T) {
+ t.Parallel()
+
+ node := Node{
+ ID: "nl-01",
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, Auth: &AuthProfile{UUID: "old-vless"}},
+ {Type: "vmess", Enabled: true, Port: 8444, Auth: &AuthProfile{UUID: "old-vmess"}},
+ {Type: "shadowsocks", Enabled: true, Port: 8443, Auth: &AuthProfile{Method: "2022-blake3-aes-128-gcm", Password: "old-ss"}},
+ {Type: "hysteria2", Enabled: true, Port: 9443, Auth: &AuthProfile{Password: "old-hy2"}, Extra: map[string]any{"obfs_password": "old-obfs"}},
+ },
+ }
+
+ rotated, err := RotateNodeSecrets(node)
+ if err != nil {
+ t.Fatalf("RotateNodeSecrets() error = %v", err)
+ }
+
+ if rotated.Protocols[0].Auth.UUID == "old-vless" || rotated.Protocols[0].Auth.UUID == "" {
+ t.Fatal("expected rotated vless uuid")
+ }
+ if rotated.Protocols[1].Auth.UUID == "old-vmess" || rotated.Protocols[1].Auth.UUID == "" {
+ t.Fatal("expected rotated vmess uuid")
+ }
+ if rotated.Protocols[2].Auth.Password == "old-ss" || rotated.Protocols[2].Auth.Password == "" {
+ t.Fatal("expected rotated shadowsocks password")
+ }
+ if rotated.Protocols[3].Auth.Password == "old-hy2" || rotated.Protocols[3].Auth.Password == "" {
+ t.Fatal("expected rotated hysteria2 password")
+ }
+ if rotated.Protocols[3].Extra["obfs_password"] == "old-obfs" || rotated.Protocols[3].Extra["obfs_password"] == "" {
+ t.Fatal("expected rotated hysteria2 obfs password")
+ }
+}
+
+func TestAddSocks5Protocol(t *testing.T) {
+ t.Parallel()
+
+ node, err := AddSocks5Protocol(Node{
+ ID: "nl-01",
+ Protocols: []ProtocolProfile{
+ {Type: "vless-reality", Enabled: true, Port: 443},
+ {Type: "hysteria2", Enabled: true, Port: 443},
+ },
+ }, 54101)
+ if err != nil {
+ t.Fatalf("AddSocks5Protocol() error = %v", err)
+ }
+ if len(node.Protocols) != 3 {
+ t.Fatalf("expected 3 protocols, got %d", len(node.Protocols))
+ }
+ last := node.Protocols[len(node.Protocols)-1]
+ if last.Type != "socks5" || last.Port != 54101 || !last.Enabled {
+ t.Fatalf("unexpected socks5 protocol: %+v", last)
+ }
+}
+
+func TestRepairReinstallNode(t *testing.T) {
+ t.Parallel()
+
+ state, err := RepairReinstallNode(context.Background(), fakeRunner{}, Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"path": "/ws"}},
+ },
+ }, t.TempDir())
+ if err != nil {
+ t.Fatalf("RepairReinstallNode() error = %v", err)
+ }
+ if state == nil {
+ t.Fatal("expected state")
+ }
+ if state.BootstrapStatus != "healthy" {
+ t.Fatalf("BootstrapStatus = %q, want healthy", state.BootstrapStatus)
+ }
+ if got := state.Metadata["lifecycle_action"]; got != "repair_reinstall" {
+ t.Fatalf("lifecycle_action = %v, want repair_reinstall", got)
+ }
+}
+
+func TestCleanReinstallNode(t *testing.T) {
+ t.Parallel()
+
+ state, err := CleanReinstallNode(context.Background(), fakeRunner{}, Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"path": "/ws"}},
+ },
+ }, t.TempDir())
+ if err != nil {
+ t.Fatalf("CleanReinstallNode() error = %v", err)
+ }
+ if state == nil {
+ t.Fatal("expected state")
+ }
+ if state.BootstrapStatus != "healthy" {
+ t.Fatalf("BootstrapStatus = %q, want healthy", state.BootstrapStatus)
+ }
+ if got := state.Metadata["lifecycle_action"]; got != "clean_reinstall" {
+ t.Fatalf("lifecycle_action = %v, want clean_reinstall", got)
+ }
+}
+
+func TestParsePreflightInspectOutput(t *testing.T) {
+ t.Parallel()
+
+ data := ParsePreflightInspectOutput("OS_ID=ubuntu\nMANAGED=1\nTCP_443=0\n")
+ if data["OS_ID"] != "ubuntu" {
+ t.Fatalf("OS_ID = %q, want ubuntu", data["OS_ID"])
+ }
+ if data["MANAGED"] != "1" {
+ t.Fatalf("MANAGED = %q, want 1", data["MANAGED"])
+ }
+ if data["TCP_443"] != "0" {
+ t.Fatalf("TCP_443 = %q, want 0", data["TCP_443"])
+ }
+}
diff --git a/internal/control/models.go b/internal/control/models.go
new file mode 100644
index 0000000..bec8e89
--- /dev/null
+++ b/internal/control/models.go
@@ -0,0 +1,66 @@
+package control
+
+type Node struct {
+ ID string `yaml:"id" json:"id"`
+ Name string `yaml:"name" json:"name"`
+ Provider string `yaml:"provider" json:"provider"`
+ Region string `yaml:"region" json:"region"`
+ Host string `yaml:"host" json:"host"`
+ Domain string `yaml:"domain,omitempty" json:"domain,omitempty"`
+ ACMEEmail string `yaml:"acme_email,omitempty" json:"acme_email,omitempty"`
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ SSH SSHConfig `yaml:"ssh" json:"ssh"`
+ Protocols []ProtocolProfile `yaml:"protocols" json:"protocols"`
+ Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
+ Metadata map[string]string `yaml:"metadata,omitempty" json:"metadata,omitempty"`
+}
+
+type SSHConfig struct {
+ User string `yaml:"user" json:"user"`
+ Port int `yaml:"port" json:"port"`
+ Auth string `yaml:"auth" json:"auth"`
+ IdentityFile string `yaml:"identity_file,omitempty" json:"identity_file,omitempty"`
+ PasswordEnv string `yaml:"password_env,omitempty" json:"password_env,omitempty"`
+ Password string `yaml:"-" json:"-"`
+}
+
+type ProtocolProfile struct {
+ Type string `yaml:"type" json:"type"`
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ Port int `yaml:"port" json:"port"`
+ TLS *TLSProfile `yaml:"tls,omitempty" json:"tls,omitempty"`
+ Auth *AuthProfile `yaml:"auth,omitempty" json:"auth,omitempty"`
+ Reality *VLESSRealityProfile `yaml:"reality,omitempty" json:"reality,omitempty"`
+ Hysteria2 *Hysteria2Profile `yaml:"hysteria2,omitempty" json:"hysteria2,omitempty"`
+ Extra map[string]any `yaml:"extra,omitempty" json:"extra,omitempty"`
+}
+
+type TLSProfile struct {
+ Enabled bool `yaml:"enabled" json:"enabled"`
+ ServerName string `yaml:"server_name,omitempty" json:"server_name,omitempty"`
+}
+
+type AuthProfile struct {
+ UUID string `yaml:"uuid,omitempty" json:"uuid,omitempty"`
+ Method string `yaml:"method,omitempty" json:"method,omitempty"`
+ Password string `yaml:"password,omitempty" json:"password,omitempty"`
+}
+
+type VLESSRealityProfile struct {
+ ServerName string `yaml:"server_name" json:"server_name"`
+ ServerPort int `yaml:"server_port,omitempty" json:"server_port,omitempty"`
+ PrivateKey string `yaml:"private_key,omitempty" json:"private_key,omitempty"`
+ PublicKey string `yaml:"public_key,omitempty" json:"public_key,omitempty"`
+ ShortID string `yaml:"short_id,omitempty" json:"short_id,omitempty"`
+ Fingerprint string `yaml:"fingerprint,omitempty" json:"fingerprint,omitempty"`
+}
+
+type Hysteria2Profile struct {
+ Port int `yaml:"port,omitempty" json:"port,omitempty"`
+ UpMbps int `yaml:"up_mbps,omitempty" json:"up_mbps,omitempty"`
+ DownMbps int `yaml:"down_mbps,omitempty" json:"down_mbps,omitempty"`
+ ObfsPassword string `yaml:"obfs_password,omitempty" json:"obfs_password,omitempty"`
+ UserPassword string `yaml:"user_password,omitempty" json:"user_password,omitempty"`
+ CertPath string `yaml:"cert_path,omitempty" json:"cert_path,omitempty"`
+ KeyPath string `yaml:"key_path,omitempty" json:"key_path,omitempty"`
+}
diff --git a/internal/control/preflight.go b/internal/control/preflight.go
new file mode 100644
index 0000000..44db7d0
--- /dev/null
+++ b/internal/control/preflight.go
@@ -0,0 +1,68 @@
+package control
+
+import "strings"
+
+func RenderPreflightInspectScript() string {
+ return `set -eu
+if [ -r /etc/os-release ]; then
+ . /etc/os-release
+fi
+printf 'OS_ID=%s\n' "${ID:-}"
+printf 'OS_PRETTY=%s\n' "${PRETTY_NAME:-}"
+printf 'OS_LIKE=%s\n' "${ID_LIKE:-}"
+if [ -d /opt/vpnem-node/current ]; then
+ printf 'MANAGED=1\n'
+else
+ printf 'MANAGED=0\n'
+fi
+if command -v docker >/dev/null 2>&1; then
+ printf 'DOCKER=1\n'
+else
+ printf 'DOCKER=0\n'
+fi
+if command -v docker >/dev/null 2>&1 && docker compose version >/dev/null 2>&1; then
+ printf 'COMPOSE=1\n'
+elif command -v docker-compose >/dev/null 2>&1; then
+ printf 'COMPOSE=1\n'
+else
+ printf 'COMPOSE=0\n'
+fi
+if command -v ss >/dev/null 2>&1; then
+ if ss -lnt 2>/dev/null | awk 'NR>1 {print $4}' | grep -Eq '(^|[:.])443$'; then
+ printf 'TCP_443=1\n'
+ else
+ printf 'TCP_443=0\n'
+ fi
+ if ss -lnu 2>/dev/null | awk 'NR>1 {print $4}' | grep -Eq '(^|[:.])443$'; then
+ printf 'UDP_443=1\n'
+ else
+ printf 'UDP_443=0\n'
+ fi
+ if ss -lnt 2>/dev/null | awk 'NR>1 {print $4}' | grep -Eq '(^|[:.])54101$'; then
+ printf 'TCP_54101=1\n'
+ else
+ printf 'TCP_54101=0\n'
+ fi
+else
+ printf 'TCP_443=unknown\n'
+ printf 'UDP_443=unknown\n'
+ printf 'TCP_54101=unknown\n'
+fi
+`
+}
+
+func ParsePreflightInspectOutput(stdout string) map[string]string {
+ values := map[string]string{}
+ for _, line := range strings.Split(stdout, "\n") {
+ line = strings.TrimSpace(line)
+ if line == "" {
+ continue
+ }
+ key, value, ok := strings.Cut(line, "=")
+ if !ok {
+ continue
+ }
+ values[strings.TrimSpace(key)] = strings.TrimSpace(value)
+ }
+ return values
+}
diff --git a/internal/control/publish.go b/internal/control/publish.go
new file mode 100644
index 0000000..d05e98b
--- /dev/null
+++ b/internal/control/publish.go
@@ -0,0 +1,321 @@
+package control
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strconv"
+
+ "vpnem/internal/models"
+)
+
+type PublishDecision struct {
+ NodeID string `json:"node_id"`
+ Eligible bool `json:"eligible"`
+ Reasons []string `json:"reasons,omitempty"`
+ PublicHost string `json:"public_host,omitempty"`
+ Status string `json:"status,omitempty"`
+}
+
+func PublishableNodes(nodes []Node, states map[string]*NodeState) []Node {
+ filtered := make([]Node, 0, len(nodes))
+ for _, node := range nodes {
+ if PublishDecisionForNode(node, states[node.ID]).Eligible {
+ filtered = append(filtered, node)
+ }
+ }
+ return filtered
+}
+
+func NodeStateReadyForPublish(state NodeState) bool {
+ if state.BootstrapStatus != "healthy" && state.BootstrapStatus != "ready" {
+ return false
+ }
+
+ if code, ok := state.Metadata["healthz_http_code"]; ok {
+ switch v := code.(type) {
+ case int:
+ if v != 200 {
+ return false
+ }
+ case float64:
+ if int(v) != 200 {
+ return false
+ }
+ }
+ }
+
+ if len(state.Services) == 0 {
+ return true
+ }
+ for _, service := range state.Services {
+ if service.Status != "running" {
+ return false
+ }
+ }
+ return true
+}
+
+func PublishDecisionForNode(node Node, state *NodeState) PublishDecision {
+ decision := PublishDecision{
+ NodeID: node.ID,
+ Eligible: false,
+ PublicHost: publicHost(node),
+ }
+
+ if !node.Enabled {
+ decision.Reasons = append(decision.Reasons, "узел выключен")
+ return decision
+ }
+ if state == nil {
+ decision.Reasons = append(decision.Reasons, "нет сохранённого состояния узла")
+ return decision
+ }
+
+ decision.Status = state.BootstrapStatus
+ if state.PublicHost != "" {
+ decision.PublicHost = state.PublicHost
+ }
+
+ if state.BootstrapStatus != "healthy" && state.BootstrapStatus != "ready" {
+ decision.Reasons = append(decision.Reasons, "статус bootstrap: "+state.BootstrapStatus)
+ return decision
+ }
+
+ if code, ok := state.Metadata["healthz_http_code"]; ok {
+ switch v := code.(type) {
+ case int:
+ if v != 200 {
+ decision.Reasons = append(decision.Reasons, "healthz_http_code: "+itoa(v))
+ }
+ case float64:
+ if int(v) != 200 {
+ decision.Reasons = append(decision.Reasons, "healthz_http_code: "+itoa(int(v)))
+ }
+ }
+ }
+
+ for _, service := range state.Services {
+ if service.Status != "running" {
+ decision.Reasons = append(decision.Reasons, "сервис "+service.Type+" имеет статус "+service.Status)
+ }
+ }
+
+ decision.Eligible = len(decision.Reasons) == 0
+ return decision
+}
+
+func PublishDecisions(nodes []Node, states map[string]*NodeState) map[string]PublishDecision {
+ decisions := make(map[string]PublishDecision, len(nodes))
+ for _, node := range nodes {
+ decisions[node.ID] = PublishDecisionForNode(node, states[node.ID])
+ }
+ return decisions
+}
+
+func itoa(v int) string { return strconv.Itoa(v) }
+
+func BuildCatalogV2(nodes []Node, states map[string]*NodeState) *models.CatalogV2 {
+ result := &models.CatalogV2{
+ Version: "2",
+ Nodes: make([]models.CatalogNode, 0, len(nodes)),
+ }
+
+ for _, node := range nodes {
+ publicHost := node.Host
+ if state := states[node.ID]; state != nil && state.PublicHost != "" {
+ publicHost = state.PublicHost
+ } else if node.Domain != "" {
+ publicHost = node.Domain
+ }
+
+ catalogNode := models.CatalogNode{
+ ID: node.ID,
+ Name: node.Name,
+ Provider: node.Provider,
+ Region: node.Region,
+ Host: node.Host,
+ Domain: node.Domain,
+ PublicHost: publicHost,
+ Tags: node.Tags,
+ Metadata: map[string]any{},
+ Protocols: make([]models.CatalogProtocol, 0, len(node.Protocols)),
+ }
+ if state := states[node.ID]; state != nil {
+ catalogNode.Status = state.BootstrapStatus
+ for k, v := range state.Metadata {
+ catalogNode.Metadata[k] = v
+ }
+ }
+
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ if err := ensureRealityProfile(&protocol); err != nil {
+ continue
+ }
+ item := models.CatalogProtocol{
+ Type: protocol.Type,
+ Enabled: protocol.Enabled,
+ Port: protocol.Port,
+ Extra: protocol.Extra,
+ }
+ if protocol.TLS != nil {
+ item.TLS = &models.TLS{
+ Enabled: protocol.TLS.Enabled,
+ ServerName: protocol.TLS.ServerName,
+ Insecure: false,
+ }
+ }
+ if protocol.Type == "vless-reality" && protocol.Reality != nil {
+ item.TLS = &models.TLS{
+ Enabled: true,
+ ServerName: protocol.Reality.ServerName,
+ Reality: &models.Reality{
+ Enabled: true,
+ PublicKey: protocol.Reality.PublicKey,
+ ShortID: protocol.Reality.ShortID,
+ Fingerprint: protocol.Reality.Fingerprint,
+ },
+ }
+ }
+ if protocol.Type == "hysteria2" {
+ if item.TLS == nil {
+ item.TLS = &models.TLS{}
+ }
+ item.TLS.Enabled = true
+ item.TLS.Insecure = true
+ if len(item.TLS.ALPN) == 0 {
+ item.TLS.ALPN = []string{defaultHysteria2ALPN}
+ }
+ if item.TLS.MinVersion == "" {
+ item.TLS.MinVersion = "1.3"
+ }
+ if item.TLS.MaxVersion == "" {
+ item.TLS.MaxVersion = "1.3"
+ }
+ }
+ if protocol.Auth != nil {
+ item.Auth = &models.CatalogAuth{
+ UUID: protocol.Auth.UUID,
+ Method: protocol.Auth.Method,
+ Password: protocol.Auth.Password,
+ }
+ }
+ catalogNode.Protocols = append(catalogNode.Protocols, item)
+ }
+ result.Nodes = append(result.Nodes, catalogNode)
+ }
+
+ return result
+}
+
+func WriteCatalogV2(path string, nodes []Node, states map[string]*NodeState) error {
+ catalog := BuildCatalogV2(nodes, states)
+
+ data, err := json.MarshalIndent(catalog, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+ tmpPath := path + ".tmp"
+ if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, path)
+}
+
+func StaticCatalogNodesFromLegacy(servers []models.Server) []models.CatalogNode {
+ nodes := make([]models.CatalogNode, 0, len(servers))
+ for _, server := range servers {
+ node := models.CatalogNode{
+ ID: server.Tag,
+ Name: server.Tag,
+ Region: server.Region,
+ Host: server.Server,
+ PublicHost: server.Server,
+ Status: "static",
+ Metadata: map[string]any{
+ "static_legacy": true,
+ },
+ Protocols: []models.CatalogProtocol{
+ {
+ Type: server.Type,
+ Enabled: true,
+ Port: server.ServerPort,
+ Auth: &models.CatalogAuth{
+ UUID: server.UUID,
+ Method: server.Method,
+ Password: server.Password,
+ },
+ TLS: server.TLS,
+ Extra: map[string]any{},
+ },
+ },
+ }
+ if server.UDPOverTCP {
+ node.Protocols[0].Extra["udp_over_tcp"] = true
+ }
+ if server.ObfsPassword != "" {
+ node.Protocols[0].Extra["obfs_password"] = server.ObfsPassword
+ }
+ if server.UpMbps > 0 {
+ node.Protocols[0].Extra["up_mbps"] = server.UpMbps
+ }
+ if server.DownMbps > 0 {
+ node.Protocols[0].Extra["down_mbps"] = server.DownMbps
+ }
+ if server.Transport != nil {
+ node.Protocols[0].Extra["transport_type"] = server.Transport.Type
+ if server.Transport.Path != "" {
+ node.Protocols[0].Extra["path"] = server.Transport.Path
+ }
+ }
+ nodes = append(nodes, node)
+ }
+ return nodes
+}
+
+func MergeCatalogNodes(primary, secondary []models.CatalogNode) []models.CatalogNode {
+ merged := make([]models.CatalogNode, 0, len(primary)+len(secondary))
+ seen := make(map[string]struct{}, len(primary)+len(secondary))
+ for _, item := range primary {
+ if _, ok := seen[item.ID]; ok {
+ continue
+ }
+ seen[item.ID] = struct{}{}
+ merged = append(merged, item)
+ }
+ for _, item := range secondary {
+ if _, ok := seen[item.ID]; ok {
+ continue
+ }
+ seen[item.ID] = struct{}{}
+ merged = append(merged, item)
+ }
+ return merged
+}
+
+func PublishLegacyCatalog(ctx context.Context, nodes []Node, targetPath string, remoteNode *Node) error {
+ if remoteNode == nil {
+ return WriteLegacyCatalog(targetPath, nodes)
+ }
+
+ tmpDir, err := os.MkdirTemp("", "vpnem-publish-*")
+ if err != nil {
+ return err
+ }
+ defer os.RemoveAll(tmpDir)
+
+ localPath := filepath.Join(tmpDir, "servers.json")
+ if err := WriteLegacyCatalog(localPath, nodes); err != nil {
+ return err
+ }
+ return CopyFileOverSCP(ctx, *remoteNode, localPath, targetPath)
+}
diff --git a/internal/control/reality.go b/internal/control/reality.go
new file mode 100644
index 0000000..301a674
--- /dev/null
+++ b/internal/control/reality.go
@@ -0,0 +1,64 @@
+package control
+
+import (
+ "crypto/rand"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "strings"
+
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+)
+
+const defaultRealityServerName = "www.nokia.com"
+
+func ensureRealityProfile(protocol *ProtocolProfile) error {
+ if protocol == nil || protocol.Type != "vless-reality" {
+ return nil
+ }
+ if protocol.Reality == nil {
+ protocol.Reality = &VLESSRealityProfile{}
+ }
+ if strings.TrimSpace(protocol.Reality.ServerName) == "" {
+ protocol.Reality.ServerName = defaultRealityServerName
+ }
+ if protocol.Reality.ServerPort == 0 {
+ protocol.Reality.ServerPort = 443
+ }
+ if strings.TrimSpace(protocol.Reality.Fingerprint) == "" {
+ protocol.Reality.Fingerprint = "chrome"
+ }
+ if strings.TrimSpace(protocol.Reality.PrivateKey) == "" || strings.TrimSpace(protocol.Reality.PublicKey) == "" {
+ privateKey, publicKey, err := generateRealityKeyPair()
+ if err != nil {
+ return err
+ }
+ protocol.Reality.PrivateKey = privateKey
+ protocol.Reality.PublicKey = publicKey
+ }
+ if strings.TrimSpace(protocol.Reality.ShortID) == "" {
+ shortID, err := generateRealityShortID()
+ if err != nil {
+ return err
+ }
+ protocol.Reality.ShortID = shortID
+ }
+ return nil
+}
+
+func generateRealityKeyPair() (privateKey string, publicKey string, err error) {
+ privateKeyPair, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return "", "", err
+ }
+ publicKeyPair := privateKeyPair.PublicKey()
+ return base64.RawURLEncoding.EncodeToString(privateKeyPair[:]), base64.RawURLEncoding.EncodeToString(publicKeyPair[:]), nil
+}
+
+func generateRealityShortID() (string, error) {
+ var raw [8]byte
+ if _, err := rand.Read(raw[:]); err != nil {
+ return "", fmt.Errorf("generate reality short id: %w", err)
+ }
+ return hex.EncodeToString(raw[:]), nil
+}
diff --git a/internal/control/runtime.go b/internal/control/runtime.go
new file mode 100644
index 0000000..93138b6
--- /dev/null
+++ b/internal/control/runtime.go
@@ -0,0 +1,586 @@
+package control
+
+import (
+ "archive/tar"
+ "compress/gzip"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "vpnem/internal/config"
+)
+
+type RuntimeBundleMeta struct {
+ ReleaseID string `json:"release_id"`
+ CreatedAt string `json:"created_at"`
+ NodeID string `json:"node_id"`
+}
+
+func RenderRuntimeBundle(dir string, node Node, releaseID string) error {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ for idx := range node.Protocols {
+ if err := ensureRealityProfile(&node.Protocols[idx]); err != nil {
+ return err
+ }
+ if err := ensureHysteria2Profile(&node.Protocols[idx]); err != nil {
+ return err
+ }
+ }
+
+ meta := RuntimeBundleMeta{
+ ReleaseID: releaseID,
+ CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ NodeID: node.ID,
+ }
+
+ files := map[string][]byte{}
+
+ nodeJSON, err := json.MarshalIndent(node, "", " ")
+ if err != nil {
+ return err
+ }
+ files["node.json"] = append(nodeJSON, '\n')
+
+ metaJSON, err := json.MarshalIndent(meta, "", " ")
+ if err != nil {
+ return err
+ }
+ files["bundle-meta.json"] = append(metaJSON, '\n')
+
+ files["node.env"] = []byte(renderNodeEnv(node))
+ files["docker-compose.yml"] = []byte(renderRuntimeCompose(node))
+ files["README.md"] = []byte(renderRuntimeReadme(node))
+ if hasHysteria2(node) {
+ certHost := hysteria2CertificateHost(node)
+ certPEM, keyPEM, err := generateSelfSignedCertForHost(certHost)
+ if err != nil {
+ return err
+ }
+ files["cert.pem"] = certPEM
+ files["key.pem"] = keyPEM
+ }
+ if config, ok, err := renderSingBoxServerConfig(node); err != nil {
+ return err
+ } else if ok {
+ files["sing-box.server.json"] = []byte(config)
+ if needsEdgeProxy(node) {
+ files["Caddyfile"] = []byte(renderCaddyfile(node))
+ }
+ }
+
+ for name, data := range files {
+ path := filepath.Join(dir, name)
+ if err := os.WriteFile(path, data, 0o644); err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func CreateTarGzFromDir(srcDir, outPath string) error {
+ outFile, err := os.Create(outPath)
+ if err != nil {
+ return err
+ }
+ defer outFile.Close()
+
+ gzw := gzip.NewWriter(outFile)
+ defer gzw.Close()
+
+ tw := tar.NewWriter(gzw)
+ defer tw.Close()
+
+ return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if info.IsDir() {
+ return nil
+ }
+
+ relPath, err := filepath.Rel(srcDir, path)
+ if err != nil {
+ return err
+ }
+
+ header, err := tar.FileInfoHeader(info, "")
+ if err != nil {
+ return err
+ }
+ header.Name = filepath.ToSlash(relPath)
+
+ if err := tw.WriteHeader(header); err != nil {
+ return err
+ }
+
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ _, err = tw.Write(data)
+ return err
+ })
+}
+
+func renderNodeEnv(node Node) string {
+ var b strings.Builder
+ writeEnv := func(key, value string) {
+ b.WriteString(key)
+ b.WriteString("=")
+ b.WriteString(sanitizeEnv(value))
+ b.WriteString("\n")
+ }
+
+ writeEnv("NODE_ID", node.ID)
+ writeEnv("NODE_NAME", node.Name)
+ writeEnv("NODE_PROVIDER", node.Provider)
+ writeEnv("NODE_REGION", node.Region)
+ writeEnv("NODE_HOST", node.Host)
+ writeEnv("NODE_DOMAIN", node.Domain)
+ writeEnv("NODE_ACME_EMAIL", node.ACMEEmail)
+ return b.String()
+}
+
+func renderRuntimeCompose(node Node) string {
+ if needsSingBoxRuntime(node) {
+ return renderSingBoxCompose(node)
+ }
+
+ var b strings.Builder
+ b.WriteString("services:\n")
+ b.WriteString(" node-info:\n")
+ b.WriteString(" image: nginx:alpine\n")
+ b.WriteString(" restart: unless-stopped\n")
+ b.WriteString(" ports:\n")
+ b.WriteString(" - \"127.0.0.1:18080:80\"\n")
+ b.WriteString(" volumes:\n")
+ b.WriteString(" - ./node.json:/usr/share/nginx/html/index.json:ro\n")
+ b.WriteString(" - ./README.md:/usr/share/nginx/html/README.md:ro\n")
+ return b.String()
+}
+
+func renderRuntimeReadme(node Node) string {
+ if hasHysteria2(node) {
+ profile := firstHysteria2Profile(node)
+ return fmt.Sprintf(
+ "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a Hysteria2 inbound on UDP `%d`\n- embedded self-signed TLS certificate\n- Salamander obfuscation enabled\n- local mixed health inbound on `127.0.0.1:1080`\n",
+ node.ID,
+ node.Region,
+ defaultInt(profile.Port, defaultHysteria2Port),
+ )
+ }
+ if usesVLESSReality(node) {
+ reality := firstRealityProfile(node)
+ return fmt.Sprintf(
+ "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a VLESS REALITY inbound on `%d`\n- no ACME or Caddy layer is required\n- REALITY handshake destination `%s:%d`\n",
+ node.ID,
+ node.Region,
+ realityPort(node),
+ reality.ServerName,
+ reality.ServerPort,
+ )
+ }
+ if usesVLESSTLS(node) {
+ return fmt.Sprintf(
+ "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIncluded runtime:\n- sing-box server with a VLESS inbound on loopback\n- Caddy terminating HTTPS with ACME certificates for `%s`\n\nRequirements:\n- the domain must resolve to this VPS\n- ports 80 and 443 must be reachable from the internet\n- acme_email should be set for certificate issuance\n",
+ node.ID,
+ node.Region,
+ node.Domain,
+ )
+ }
+
+ return fmt.Sprintf(
+ "# vpnem node bundle\n\nThis bundle was generated for node `%s` in region `%s`.\n\nIt contains inventory metadata and a minimal runtime placeholder. Replace or extend the runtime services as protocol-specific deployers are added.\n",
+ node.ID,
+ node.Region,
+ )
+}
+
+func sanitizeEnv(value string) string {
+ value = strings.ReplaceAll(value, "\n", "")
+ return value
+}
+
+func usesVLESSTLS(node Node) bool {
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "vless" && protocol.Enabled && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func usesVLESSReality(node Node) bool {
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "vless-reality" && protocol.Enabled {
+ return true
+ }
+ }
+ return false
+}
+
+func usesVMessTLS(node Node) bool {
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "vmess" && protocol.Enabled && protocol.TLS != nil && protocol.TLS.Enabled && strings.TrimSpace(node.Domain) != "" {
+ return true
+ }
+ }
+ return false
+}
+
+func needsEdgeProxy(node Node) bool {
+ return usesVLESSTLS(node) || usesVMessTLS(node)
+}
+
+func needsSingBoxRuntime(node Node) bool {
+ for _, protocol := range node.Protocols {
+ if protocol.Enabled {
+ return true
+ }
+ }
+ return false
+}
+
+func renderSingBoxCompose(node Node) string {
+ var b strings.Builder
+ b.WriteString("services:\n")
+ b.WriteString(" sing-box:\n")
+ b.WriteString(" image: ghcr.io/sagernet/sing-box:v1.12.20\n")
+ b.WriteString(" restart: unless-stopped\n")
+ b.WriteString(" command: [\"run\", \"-c\", \"/etc/sing-box/config.json\"]\n")
+ if isHysteria2Only(node) {
+ hy2Port := defaultInt(firstHysteria2Profile(node).Port, defaultHysteria2Port)
+ b.WriteString(" ports:\n")
+ b.WriteString(fmt.Sprintf(" - \"%d:%d/udp\"\n", hy2Port, hy2Port))
+ b.WriteString(" - \"127.0.0.1:1080:1080/tcp\"\n")
+ } else {
+ b.WriteString(" network_mode: host\n")
+ }
+ b.WriteString(" volumes:\n")
+ b.WriteString(" - ./sing-box.server.json:/etc/sing-box/config.json:ro\n")
+ if hasHysteria2(node) {
+ b.WriteString(" - ./cert.pem:/etc/sing-box/cert.pem:ro\n")
+ b.WriteString(" - ./key.pem:/etc/sing-box/key.pem:ro\n")
+ }
+ b.WriteString("\n")
+ if needsEdgeProxy(node) {
+ b.WriteString(" caddy:\n")
+ b.WriteString(" image: caddy:2\n")
+ b.WriteString(" restart: unless-stopped\n")
+ b.WriteString(" network_mode: host\n")
+ b.WriteString(" depends_on:\n")
+ b.WriteString(" - sing-box\n")
+ b.WriteString(" environment:\n")
+ if strings.TrimSpace(node.ACMEEmail) != "" {
+ b.WriteString(" ACME_EMAIL: " + node.ACMEEmail + "\n")
+ }
+ b.WriteString(" volumes:\n")
+ b.WriteString(" - ./Caddyfile:/etc/caddy/Caddyfile:ro\n")
+ b.WriteString(" - caddy_data:/data\n")
+ b.WriteString(" - caddy_config:/config\n")
+ b.WriteString("\n")
+ b.WriteString("volumes:\n")
+ b.WriteString(" caddy_data:\n")
+ b.WriteString(" caddy_config:\n")
+ }
+ return b.String()
+}
+
+func renderSingBoxServerConfig(node Node) (string, bool, error) {
+ inbounds := make([]map[string]any, 0)
+ if !needsSingBoxRuntime(node) {
+ return "", false, nil
+ }
+
+ if vless, ok := findProtocol(node, "vless"); ok && vless.Enabled {
+ if vless.Auth == nil || strings.TrimSpace(vless.Auth.UUID) == "" {
+ return "", false, fmt.Errorf("vless runtime requires auth.uuid")
+ }
+ inbound := map[string]any{
+ "type": "vless",
+ "tag": "vless-in",
+ "users": []map[string]any{
+ {"uuid": vless.Auth.UUID},
+ },
+ }
+ path := stringFromExtra(vless.Extra, "path")
+ if path == "" {
+ path = "/ws"
+ }
+ if vless.TLS != nil && vless.TLS.Enabled && strings.TrimSpace(node.Domain) != "" {
+ inbound["listen"] = "127.0.0.1"
+ inbound["listen_port"] = 10443
+ inbound["transport"] = map[string]any{
+ "type": "ws",
+ "path": path,
+ }
+ } else {
+ inbound["listen"] = "0.0.0.0"
+ inbound["listen_port"] = vless.Port
+ }
+ inbounds = append(inbounds, inbound)
+ }
+
+ if reality, ok := findProtocol(node, "vless-reality"); ok && reality.Enabled {
+ if reality.Auth == nil || strings.TrimSpace(reality.Auth.UUID) == "" {
+ return "", false, fmt.Errorf("vless-reality runtime requires auth.uuid")
+ }
+ if err := ensureRealityProfile(&reality); err != nil {
+ return "", false, err
+ }
+ inbound := map[string]any{
+ "type": "vless",
+ "tag": "vless-reality-in",
+ "listen": "::",
+ "listen_port": reality.Port,
+ "users": []map[string]any{
+ {"uuid": reality.Auth.UUID},
+ },
+ "tls": map[string]any{
+ "enabled": true,
+ "server_name": reality.Reality.ServerName,
+ "reality": map[string]any{
+ "enabled": true,
+ "handshake": map[string]any{
+ "server": reality.Reality.ServerName,
+ "server_port": defaultInt(reality.Reality.ServerPort, 443),
+ },
+ "private_key": reality.Reality.PrivateKey,
+ "short_id": []string{reality.Reality.ShortID},
+ },
+ },
+ }
+ inbounds = append(inbounds, inbound)
+ }
+
+ if ss, ok := findProtocol(node, "shadowsocks"); ok && ss.Enabled {
+ if ss.Auth == nil || strings.TrimSpace(ss.Auth.Method) == "" || strings.TrimSpace(ss.Auth.Password) == "" {
+ return "", false, fmt.Errorf("shadowsocks runtime requires auth.method and auth.password")
+ }
+ inbounds = append(inbounds, map[string]any{
+ "type": "shadowsocks",
+ "tag": "ss-in",
+ "listen": "0.0.0.0",
+ "listen_port": ss.Port,
+ "method": ss.Auth.Method,
+ "password": ss.Auth.Password,
+ })
+ }
+
+ if socks, ok := findProtocol(node, "socks"); ok && socks.Enabled {
+ inbounds = append(inbounds, map[string]any{
+ "type": "socks",
+ "tag": "socks-in",
+ "listen": "0.0.0.0",
+ "listen_port": socks.Port,
+ })
+ }
+ if socks, ok := findProtocol(node, "socks5"); ok && socks.Enabled {
+ inbounds = append(inbounds, map[string]any{
+ "type": "socks",
+ "tag": "socks5-in",
+ "listen": "0.0.0.0",
+ "listen_port": socks.Port,
+ })
+ }
+
+ if vmess, ok := findProtocol(node, "vmess"); ok && vmess.Enabled {
+ if vmess.Auth == nil || strings.TrimSpace(vmess.Auth.UUID) == "" {
+ return "", false, fmt.Errorf("vmess runtime requires auth.uuid")
+ }
+ inbound := map[string]any{
+ "type": "vmess",
+ "tag": "vmess-in",
+ "users": []map[string]any{
+ {"uuid": vmess.Auth.UUID, "alterId": 0},
+ },
+ }
+ path := stringFromExtra(vmess.Extra, "path")
+ if path == "" {
+ path = "/vmess"
+ }
+ if vmess.TLS != nil && vmess.TLS.Enabled && strings.TrimSpace(node.Domain) != "" {
+ inbound["listen"] = "127.0.0.1"
+ inbound["listen_port"] = 10444
+ inbound["transport"] = map[string]any{
+ "type": "ws",
+ "path": path,
+ }
+ } else {
+ inbound["listen"] = "0.0.0.0"
+ inbound["listen_port"] = vmess.Port
+ }
+ inbounds = append(inbounds, inbound)
+ }
+
+ if hy2, ok := findProtocol(node, "hysteria2"); ok && hy2.Enabled {
+ profile := hy2.Hysteria2
+ if profile == nil {
+ return "", false, fmt.Errorf("hysteria2 runtime requires hysteria2 settings")
+ }
+ inboundConfig, err := config.BuildHysteria2Inbound(node, hy2.Port, profile.UserPassword, profile.ObfsPassword, profile.UpMbps, profile.DownMbps, profile.CertPath, profile.KeyPath)
+ if err != nil {
+ return "", false, err
+ }
+ inbound := map[string]any(*inboundConfig)
+ inbound["users"] = []map[string]any{
+ {"name": node.ID, "password": profile.UserPassword},
+ }
+ inbounds = append(inbounds, inbound)
+ if needsHysteria2HealthInbound(node) {
+ inbounds = append(inbounds, map[string]any{
+ "type": "mixed",
+ "tag": "hy2-health-in",
+ "listen": "127.0.0.1",
+ "listen_port": 1080,
+ })
+ }
+ }
+
+ config := map[string]any{
+ "log": map[string]any{"level": "info"},
+ "inbounds": inbounds,
+ "outbounds": []map[string]any{
+ {"type": "direct", "tag": "direct"},
+ },
+ }
+
+ data, err := json.MarshalIndent(config, "", " ")
+ if err != nil {
+ return "", false, err
+ }
+ return string(data) + "\n", true, nil
+}
+
+func renderCaddyfile(node Node) string {
+ var b strings.Builder
+ b.WriteString("{\n")
+ if strings.TrimSpace(node.ACMEEmail) != "" {
+ b.WriteString(" email ")
+ b.WriteString(node.ACMEEmail)
+ b.WriteString("\n")
+ }
+ b.WriteString("}\n\n")
+ b.WriteString(node.Domain)
+ b.WriteString(" {\n")
+ b.WriteString(" encode zstd gzip\n")
+ if vless, ok := findProtocol(node, "vless"); ok && vless.Enabled && vless.TLS != nil && vless.TLS.Enabled {
+ path := stringFromExtra(vless.Extra, "path")
+ if path == "" {
+ path = "/ws"
+ }
+ b.WriteString(" @vless path ")
+ b.WriteString(path)
+ b.WriteString("\n")
+ b.WriteString(" reverse_proxy @vless 127.0.0.1:10443\n")
+ }
+ if vmess, ok := findProtocol(node, "vmess"); ok && vmess.Enabled && vmess.TLS != nil && vmess.TLS.Enabled {
+ path := stringFromExtra(vmess.Extra, "path")
+ if path == "" {
+ path = "/vmess"
+ }
+ b.WriteString(" @vmess path ")
+ b.WriteString(path)
+ b.WriteString("\n")
+ b.WriteString(" reverse_proxy @vmess 127.0.0.1:10444\n")
+ }
+ b.WriteString(" respond /healthz 200\n")
+ b.WriteString("}\n")
+ return b.String()
+}
+
+func firstRealityProfile(node Node) VLESSRealityProfile {
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "vless-reality" && protocol.Enabled && protocol.Reality != nil {
+ return *protocol.Reality
+ }
+ }
+ return VLESSRealityProfile{}
+}
+
+func firstHysteria2Profile(node Node) Hysteria2Profile {
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "hysteria2" && protocol.Enabled && protocol.Hysteria2 != nil {
+ return *protocol.Hysteria2
+ }
+ }
+ return Hysteria2Profile{}
+}
+
+func realityPort(node Node) int {
+ for _, protocol := range node.Protocols {
+ if protocol.Type == "vless-reality" && protocol.Enabled {
+ return protocol.Port
+ }
+ }
+ return 443
+}
+
+func defaultInt(value, fallback int) int {
+ if value > 0 {
+ return value
+ }
+ return fallback
+}
+
+func findProtocol(node Node, kind string) (ProtocolProfile, bool) {
+ for _, protocol := range node.Protocols {
+ if protocol.Type == kind {
+ return protocol, true
+ }
+ }
+ return ProtocolProfile{}, false
+}
+
+func hasHysteria2(node Node) bool {
+ hy2, ok := findProtocol(node, "hysteria2")
+ return ok && hy2.Enabled
+}
+
+func isHysteria2Only(node Node) bool {
+ enabled := 0
+ hy2Enabled := false
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ enabled++
+ if protocol.Type == "hysteria2" {
+ hy2Enabled = true
+ }
+ }
+ return enabled == 1 && hy2Enabled
+}
+
+func needsHysteria2HealthInbound(node Node) bool {
+ return hasHysteria2(node)
+}
+
+func hysteria2CertificateHost(node Node) string {
+ if tls, ok := findProtocol(node, "hysteria2"); ok && tls.TLS != nil && strings.TrimSpace(tls.TLS.ServerName) != "" {
+ return strings.TrimSpace(tls.TLS.ServerName)
+ }
+ suffix := strings.ReplaceAll(strings.ToLower(node.ID), "_", "-")
+ suffix = strings.ReplaceAll(suffix, " ", "-")
+ return "node-" + suffix + ".local"
+}
+
+func intFromExtra(extra map[string]any, key string, fallback int) int {
+ if extra == nil {
+ return fallback
+ }
+ switch value := extra[key].(type) {
+ case int:
+ return value
+ case float64:
+ return int(value)
+ default:
+ return fallback
+ }
+}
diff --git a/internal/control/runtime_test.go b/internal/control/runtime_test.go
new file mode 100644
index 0000000..1f38dd7
--- /dev/null
+++ b/internal/control/runtime_test.go
@@ -0,0 +1,307 @@
+package control
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestRenderRuntimeBundle(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ node := Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ ACMEEmail: "admin@example.com",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &TLSProfile{
+ Enabled: true,
+ ServerName: "nl-01.example.com",
+ },
+ Auth: &AuthProfile{
+ UUID: "11111111-1111-1111-1111-111111111111",
+ },
+ Extra: map[string]any{
+ "path": "/ws",
+ },
+ },
+ },
+ }
+
+ if err := RenderRuntimeBundle(dir, node, "20260401-123000"); err != nil {
+ t.Fatalf("RenderRuntimeBundle error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
+ if err != nil {
+ t.Fatalf("ReadFile docker-compose.yml error = %v", err)
+ }
+ if !strings.Contains(string(data), "sing-box:") {
+ t.Fatal("expected sing-box service in runtime compose")
+ }
+ if !strings.Contains(string(data), "caddy:") {
+ t.Fatal("expected caddy service in runtime compose")
+ }
+
+ caddyfile, err := os.ReadFile(filepath.Join(dir, "Caddyfile"))
+ if err != nil {
+ t.Fatalf("ReadFile Caddyfile error = %v", err)
+ }
+ if !strings.Contains(string(caddyfile), "nl-01.example.com") {
+ t.Fatal("expected domain in Caddyfile")
+ }
+
+ serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json"))
+ if err != nil {
+ t.Fatalf("ReadFile sing-box.server.json error = %v", err)
+ }
+ if !strings.Contains(string(serverConfig), "\"type\": \"vless\"") {
+ t.Fatal("expected vless inbound in sing-box config")
+ }
+}
+
+func TestRenderRuntimeBundleReality(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ node := Node{
+ ID: "nl-reality",
+ Name: "NL Reality",
+ Region: "nl",
+ Host: "203.0.113.20",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ Auth: &AuthProfile{
+ UUID: "33333333-3333-3333-3333-333333333333",
+ },
+ Reality: &VLESSRealityProfile{
+ ServerName: "login.microsoftonline.com",
+ ServerPort: 443,
+ PrivateKey: "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc",
+ PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0",
+ ShortID: "0123456789abcdef",
+ Fingerprint: "chrome",
+ },
+ },
+ },
+ }
+
+ if err := RenderRuntimeBundle(dir, node, "20260408-180000"); err != nil {
+ t.Fatalf("RenderRuntimeBundle error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
+ if err != nil {
+ t.Fatalf("ReadFile docker-compose.yml error = %v", err)
+ }
+ if !strings.Contains(string(data), "sing-box:") {
+ t.Fatal("expected sing-box service in runtime compose")
+ }
+ if strings.Contains(string(data), "caddy:") {
+ t.Fatal("did not expect caddy service for reality runtime")
+ }
+
+ if _, err := os.Stat(filepath.Join(dir, "Caddyfile")); !os.IsNotExist(err) {
+ t.Fatal("did not expect Caddyfile for reality runtime")
+ }
+
+ serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json"))
+ if err != nil {
+ t.Fatalf("ReadFile sing-box.server.json error = %v", err)
+ }
+ s := string(serverConfig)
+ if !strings.Contains(s, "\"private_key\": \"UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc\"") {
+ t.Fatal("expected reality private key in sing-box config")
+ }
+ if !strings.Contains(s, "\"short_id\": [") || !strings.Contains(s, "0123456789abcdef") {
+ t.Fatal("expected reality short id in sing-box config")
+ }
+ if !strings.Contains(s, "login.microsoftonline.com") {
+ t.Fatal("expected reality handshake destination in sing-box config")
+ }
+}
+
+func TestHysteria2Bundle(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ node := Node{
+ ID: "nl-hy2",
+ Name: "NL Hysteria2",
+ Region: "nl",
+ Host: "203.0.113.30",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 443,
+ Auth: &AuthProfile{
+ Password: "user-password",
+ },
+ Hysteria2: &Hysteria2Profile{
+ Port: 443,
+ UpMbps: 100,
+ DownMbps: 100,
+ ObfsPassword: "obfs-password",
+ UserPassword: "user-password",
+ CertPath: "/etc/sing-box/cert.pem",
+ KeyPath: "/etc/sing-box/key.pem",
+ },
+ },
+ },
+ }
+
+ if err := RenderRuntimeBundle(dir, node, "20260408-220000"); err != nil {
+ t.Fatalf("RenderRuntimeBundle error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
+ if err != nil {
+ t.Fatalf("ReadFile docker-compose.yml error = %v", err)
+ }
+ compose := string(data)
+ if !strings.Contains(compose, "443:443/udp") {
+ t.Fatal("expected udp port mapping for hysteria2 runtime")
+ }
+ if !strings.Contains(compose, "127.0.0.1:1080:1080/tcp") {
+ t.Fatal("expected local tcp health port mapping for hysteria2 runtime")
+ }
+ if strings.Contains(compose, "caddy:") {
+ t.Fatal("did not expect caddy service for hysteria2 runtime")
+ }
+
+ serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json"))
+ if err != nil {
+ t.Fatalf("ReadFile sing-box.server.json error = %v", err)
+ }
+ config := string(serverConfig)
+ if !strings.Contains(config, "\"type\": \"hysteria2\"") {
+ t.Fatal("expected hysteria2 inbound in sing-box config")
+ }
+ if !strings.Contains(config, "\"salamander\"") {
+ t.Fatal("expected salamander obfuscation in sing-box config")
+ }
+ if !strings.Contains(config, "\"listen_port\": 1080") {
+ t.Fatal("expected mixed health inbound in sing-box config")
+ }
+ if !strings.Contains(config, "\"certificate_path\": \"/etc/sing-box/cert.pem\"") {
+ t.Fatal("expected embedded certificate path in sing-box config")
+ }
+ if _, err := os.Stat(filepath.Join(dir, "cert.pem")); err != nil {
+ t.Fatalf("expected generated cert.pem: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "key.pem")); err != nil {
+ t.Fatalf("expected generated key.pem: %v", err)
+ }
+}
+
+func TestRenderRuntimeBundleMultiProtocol(t *testing.T) {
+ t.Parallel()
+
+ dir := t.TempDir()
+ node := Node{
+ ID: "nl-multi",
+ Name: "NL Multi",
+ Region: "nl",
+ Host: "203.0.113.40",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "key",
+ },
+ Protocols: []ProtocolProfile{
+ {
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ Auth: &AuthProfile{
+ UUID: "33333333-3333-3333-3333-333333333333",
+ },
+ Reality: &VLESSRealityProfile{
+ ServerName: "www.microsoft.com",
+ ServerPort: 443,
+ PrivateKey: "UuMBgl7MXTPx9inmQp2UC7Jcnwc6XYbwDNebonM-FCc",
+ PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0",
+ ShortID: "0123456789abcdef",
+ Fingerprint: "chrome",
+ },
+ },
+ {
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 443,
+ Auth: &AuthProfile{
+ Password: "user-password",
+ },
+ Hysteria2: &Hysteria2Profile{
+ Port: 443,
+ UpMbps: 100,
+ DownMbps: 100,
+ ObfsPassword: "obfs-password",
+ UserPassword: "user-password",
+ CertPath: "/etc/sing-box/cert.pem",
+ KeyPath: "/etc/sing-box/key.pem",
+ },
+ },
+ },
+ }
+
+ if err := RenderRuntimeBundle(dir, node, "20260409-120000"); err != nil {
+ t.Fatalf("RenderRuntimeBundle error = %v", err)
+ }
+
+ data, err := os.ReadFile(filepath.Join(dir, "docker-compose.yml"))
+ if err != nil {
+ t.Fatalf("ReadFile docker-compose.yml error = %v", err)
+ }
+ compose := string(data)
+ if !strings.Contains(compose, "network_mode: host") {
+ t.Fatal("expected host networking for multi protocol runtime")
+ }
+
+ serverConfig, err := os.ReadFile(filepath.Join(dir, "sing-box.server.json"))
+ if err != nil {
+ t.Fatalf("ReadFile sing-box.server.json error = %v", err)
+ }
+ config := string(serverConfig)
+ if !strings.Contains(config, "\"tag\": \"vless-reality-in\"") {
+ t.Fatal("expected reality inbound in sing-box config")
+ }
+ if !strings.Contains(config, "\"tag\": \"hysteria2-in\"") {
+ t.Fatal("expected hysteria2 inbound in sing-box config")
+ }
+ if !strings.Contains(config, "\"tag\": \"hy2-health-in\"") {
+ t.Fatal("expected hysteria2 health inbound for multi runtime")
+ }
+}
diff --git a/internal/control/ssh.go b/internal/control/ssh.go
new file mode 100644
index 0000000..b7d7dd5
--- /dev/null
+++ b/internal/control/ssh.go
@@ -0,0 +1,182 @@
+package control
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+type SSHRunner struct{}
+
+type SSHExecutor interface {
+ Run(ctx context.Context, node Node, script string) (*CommandResult, error)
+ Check(ctx context.Context, node Node) (*CommandResult, error)
+ CopyFile(ctx context.Context, node Node, localPath, remotePath string) error
+}
+
+type CommandResult struct {
+ Stdout string
+ Stderr string
+}
+
+func (r SSHRunner) Run(ctx context.Context, node Node, script string) (*CommandResult, error) {
+ target := sshTarget(node)
+ cmd, err := sshCommand(ctx, node, target, "sh -s")
+ if err != nil {
+ return &CommandResult{}, err
+ }
+ cmd.Stdin = strings.NewReader(script)
+
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, fmt.Errorf("ssh %s: %w", target, err)
+ }
+
+ return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, nil
+}
+
+func (r SSHRunner) Check(ctx context.Context, node Node) (*CommandResult, error) {
+ target := sshTarget(node)
+ cmd, err := sshCommand(ctx, node, target, "printf ok")
+ if err != nil {
+ return &CommandResult{}, err
+ }
+
+ var stdout bytes.Buffer
+ var stderr bytes.Buffer
+ cmd.Stdout = &stdout
+ cmd.Stderr = &stderr
+
+ if err := cmd.Run(); err != nil {
+ return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, fmt.Errorf("ssh %s: %w", target, err)
+ }
+
+ return &CommandResult{Stdout: stdout.String(), Stderr: stderr.String()}, nil
+}
+
+func CopyFileOverSCP(ctx context.Context, node Node, localPath, remotePath string) error {
+ target := fmt.Sprintf("%s:%s", sshTarget(node), remotePath)
+ cmd, err := scpCommand(ctx, node, localPath, target)
+ if err != nil {
+ return err
+ }
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("scp %s -> %s: %w: %s", localPath, target, err, string(output))
+ }
+ return nil
+}
+
+func (r SSHRunner) CopyFile(ctx context.Context, node Node, localPath, remotePath string) error {
+ return CopyFileOverSCP(ctx, node, localPath, remotePath)
+}
+
+func CopyDirContentsOverSCP(ctx context.Context, node Node, localDir, remoteDir string) error {
+ target := fmt.Sprintf("%s:%s", sshTarget(node), remoteDir)
+ cmd, err := scpCommand(ctx, node, "-r", filepath.Clean(localDir)+"/.", target)
+ if err != nil {
+ return err
+ }
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("scp %s -> %s: %w: %s", localDir, target, err, string(output))
+ }
+ return nil
+}
+
+func sshBaseArgs(node Node) []string {
+ args := []string{
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-p", strconv.Itoa(defaultSSHPort(node.SSH.Port)),
+ }
+ if strings.TrimSpace(node.SSH.Auth) == "password" {
+ args = append(args, "-o", "BatchMode=no")
+ } else {
+ args = append(args, "-o", "BatchMode=yes")
+ }
+ if strings.TrimSpace(node.SSH.IdentityFile) != "" {
+ args = append(args, "-i", expandHome(node.SSH.IdentityFile))
+ }
+ return args
+}
+
+func scpBaseArgs(node Node) []string {
+ args := []string{
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-P", strconv.Itoa(defaultSSHPort(node.SSH.Port)),
+ }
+ if strings.TrimSpace(node.SSH.Auth) == "password" {
+ args = append(args, "-o", "BatchMode=no")
+ } else {
+ args = append(args, "-o", "BatchMode=yes")
+ }
+ if strings.TrimSpace(node.SSH.IdentityFile) != "" {
+ args = append(args, "-i", expandHome(node.SSH.IdentityFile))
+ }
+ return args
+}
+
+func sshTarget(node Node) string {
+ return fmt.Sprintf("%s@%s", node.SSH.User, node.Host)
+}
+
+func defaultSSHPort(port int) int {
+ if port == 0 {
+ return 22
+ }
+ return port
+}
+
+func expandHome(path string) string {
+ if path == "" || path[0] != '~' {
+ return path
+ }
+ home, err := exec.Command("sh", "-lc", "printf %s \"$HOME\"").Output()
+ if err != nil {
+ return path
+ }
+ return filepath.Join(strings.TrimSpace(string(home)), strings.TrimPrefix(path, "~/"))
+}
+
+func sshCommand(ctx context.Context, node Node, extraArgs ...string) (*exec.Cmd, error) {
+ args := sshBaseArgs(node)
+ args = append(args, extraArgs...)
+ return wrapWithPassword(ctx, node, "ssh", args...)
+}
+
+func scpCommand(ctx context.Context, node Node, extraArgs ...string) (*exec.Cmd, error) {
+ args := scpBaseArgs(node)
+ args = append(args, extraArgs...)
+ return wrapWithPassword(ctx, node, "scp", args...)
+}
+
+func wrapWithPassword(ctx context.Context, node Node, command string, args ...string) (*exec.Cmd, error) {
+ if strings.TrimSpace(node.SSH.Auth) != "password" {
+ return exec.CommandContext(ctx, command, args...), nil
+ }
+
+ password := node.SSH.Password
+ if password == "" {
+ envName := strings.TrimSpace(node.SSH.PasswordEnv)
+ if envName == "" {
+ return nil, fmt.Errorf("ssh password auth for %s requires ssh.password_env", sshTarget(node))
+ }
+ password = os.Getenv(envName)
+ if password == "" {
+ return nil, fmt.Errorf("ssh password env %s is empty", envName)
+ }
+ }
+
+ wrappedArgs := append([]string{"-p", password, command}, args...)
+ cmd := exec.CommandContext(ctx, "sshpass", wrappedArgs...)
+ return cmd, nil
+}
diff --git a/internal/control/ssh_test.go b/internal/control/ssh_test.go
new file mode 100644
index 0000000..8b7afd0
--- /dev/null
+++ b/internal/control/ssh_test.go
@@ -0,0 +1,80 @@
+package control
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestValidateNodeSSHPasswordAuth(t *testing.T) {
+ t.Parallel()
+
+ node := Node{
+ ID: "pw-01",
+ Name: "Password Node",
+ Provider: "custom-vps",
+ Region: "nl",
+ Host: "203.0.113.20",
+ Enabled: true,
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "password",
+ PasswordEnv: "VPNEM_TEST_PASSWORD",
+ },
+ Protocols: []ProtocolProfile{
+ {Type: "socks5", Enabled: true, Port: 1080},
+ },
+ }
+
+ if err := ValidateNode(node); err != nil {
+ t.Fatalf("ValidateNode() error = %v", err)
+ }
+}
+
+func TestWrapWithPasswordUsesSSHPass(t *testing.T) {
+ t.Setenv("VPNEM_TEST_PASSWORD", "secret")
+ node := Node{
+ ID: "pw-01",
+ Name: "Password Node",
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "password",
+ PasswordEnv: "VPNEM_TEST_PASSWORD",
+ },
+ }
+
+ cmd, err := wrapWithPassword(context.Background(), node, "ssh", "-V")
+ if err != nil {
+ t.Fatalf("wrapWithPassword() error = %v", err)
+ }
+ if got := filepath.Base(cmd.Path); got != "sshpass" {
+ t.Fatalf("filepath.Base(cmd.Path) = %q, want sshpass", got)
+ }
+ if len(cmd.Args) < 4 {
+ t.Fatalf("cmd.Args too short: %#v", cmd.Args)
+ }
+ if cmd.Args[1] != "-p" || cmd.Args[2] != "secret" || cmd.Args[3] != "ssh" {
+ t.Fatalf("unexpected cmd.Args: %#v", cmd.Args)
+ }
+}
+
+func TestWrapWithPasswordRequiresEnv(t *testing.T) {
+ _ = os.Unsetenv("VPNEM_TEST_PASSWORD_MISSING")
+ node := Node{
+ ID: "pw-01",
+ Name: "Password Node",
+ SSH: SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "password",
+ PasswordEnv: "VPNEM_TEST_PASSWORD_MISSING",
+ },
+ }
+
+ if _, err := wrapWithPassword(context.Background(), node, "ssh", "-V"); err == nil {
+ t.Fatal("expected error for missing password env")
+ }
+}
diff --git a/internal/control/state.go b/internal/control/state.go
new file mode 100644
index 0000000..7fc7827
--- /dev/null
+++ b/internal/control/state.go
@@ -0,0 +1,71 @@
+package control
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "sort"
+ "time"
+)
+
+type NodeState struct {
+ NodeID string `json:"node_id"`
+ BootstrapStatus string `json:"bootstrap_status"`
+ LastBootstrapAt *time.Time `json:"last_bootstrap_at,omitempty"`
+ LastHealthCheckAt *time.Time `json:"last_health_check_at,omitempty"`
+ LastDNSSyncAt *time.Time `json:"last_dns_sync_at,omitempty"`
+ PublicHost string `json:"public_host,omitempty"`
+ Services []ServiceStatus `json:"services,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type ServiceStatus struct {
+ Type string `json:"type"`
+ Status string `json:"status"`
+ Port int `json:"port"`
+}
+
+func LoadNodeState(dir, nodeID string) (*NodeState, error) {
+ data, err := os.ReadFile(filepath.Join(dir, nodeID+".json"))
+ if err != nil {
+ return nil, err
+ }
+
+ var state NodeState
+ if err := json.Unmarshal(data, &state); err != nil {
+ return nil, err
+ }
+ return &state, nil
+}
+
+func SaveNodeState(dir string, state NodeState) error {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+
+ sort.Slice(state.Services, func(i, j int) bool {
+ return state.Services[i].Type < state.Services[j].Type
+ })
+
+ data, err := json.MarshalIndent(state, "", " ")
+ if err != nil {
+ return err
+ }
+ data = append(data, '\n')
+
+ tmpPath := filepath.Join(dir, state.NodeID+".json.tmp")
+ finalPath := filepath.Join(dir, state.NodeID+".json")
+ if err := os.WriteFile(tmpPath, data, 0o600); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, finalPath)
+}
+
+func DeleteNodeState(dir, nodeID string) error {
+ err := os.Remove(filepath.Join(dir, nodeID+".json"))
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ return err
+}
diff --git a/internal/control/upgrade_test.go b/internal/control/upgrade_test.go
new file mode 100644
index 0000000..c3e5404
--- /dev/null
+++ b/internal/control/upgrade_test.go
@@ -0,0 +1,55 @@
+package control
+
+import (
+ "context"
+ "strings"
+ "testing"
+)
+
+type fakeRunner struct{}
+
+func (fakeRunner) Run(ctx context.Context, node Node, script string) (*CommandResult, error) {
+ if strings.Contains(script, "HEALTHZ_HTTP_CODE=") {
+ return &CommandResult{
+ Stdout: "{\"Service\":\"sing-box\",\"Status\":\"running\"}\nHEALTHZ_HTTP_CODE=200\n",
+ }, nil
+ }
+ return &CommandResult{Stdout: "ok\n"}, nil
+}
+
+func (fakeRunner) Check(ctx context.Context, node Node) (*CommandResult, error) {
+ return &CommandResult{Stdout: "ok"}, nil
+}
+
+func (fakeRunner) CopyFile(ctx context.Context, node Node, localPath, remotePath string) error {
+ return nil
+}
+
+func TestUpgradeNode(t *testing.T) {
+ t.Parallel()
+
+ state, err := UpgradeNode(context.Background(), fakeRunner{}, Node{
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ Domain: "nl-01.example.com",
+ Enabled: true,
+ SSH: SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"},
+ Protocols: []ProtocolProfile{
+ {Type: "vless", Enabled: true, Port: 443, TLS: &TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, Auth: &AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, Extra: map[string]any{"path": "/ws"}},
+ },
+ }, t.TempDir())
+ if err != nil {
+ t.Fatalf("UpgradeNode() error = %v", err)
+ }
+ if state == nil {
+ t.Fatal("expected state")
+ }
+ if state.BootstrapStatus != "healthy" {
+ t.Fatalf("BootstrapStatus = %q, want healthy", state.BootstrapStatus)
+ }
+ if got := state.Metadata["lifecycle_action"]; got != "upgrade" {
+ t.Fatalf("lifecycle_action = %v, want upgrade", got)
+ }
+}
diff --git a/internal/engine/engine.go b/internal/engine/engine.go
new file mode 100644
index 0000000..fa145ee
--- /dev/null
+++ b/internal/engine/engine.go
@@ -0,0 +1,138 @@
+package engine
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "log"
+ "os"
+ "path/filepath"
+ "sync"
+
+ box "github.com/sagernet/sing-box"
+ "github.com/sagernet/sing-box/include"
+ "github.com/sagernet/sing-box/option"
+
+ "vpnem/internal/config"
+ "vpnem/internal/models"
+)
+
+type Engine struct {
+ mu sync.Mutex
+ instance *box.Box
+ cancel context.CancelFunc
+ running bool
+ configPath string
+ dataDir string
+ localProxyPort int
+}
+
+func New(dataDir string) *Engine {
+ return &Engine{
+ dataDir: dataDir,
+ configPath: filepath.Join(dataDir, "config.json"),
+ }
+}
+
+func (e *Engine) Start(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error {
+ return e.StartFull(server, mode, ruleSets, serverIPs, nil, config.LocalProxyPort, nil)
+}
+
+func (e *Engine) StartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ if e.running {
+ return fmt.Errorf("already running")
+ }
+
+ cfg := config.BuildConfigFullWithLocalProxy(server, mode, ruleSets, serverIPs, customBypass, localProxyPort, policy)
+ data, err := json.MarshalIndent(cfg, "", " ")
+ if err != nil {
+ return fmt.Errorf("marshal config: %w", err)
+ }
+
+ os.MkdirAll(e.dataDir, 0o755)
+ _ = os.WriteFile(e.configPath, data, 0o644)
+ log.Printf("engine: config saved (%d bytes)", len(data))
+
+ var opts option.Options
+ ctx := include.Context(context.Background())
+ if err := opts.UnmarshalJSONContext(ctx, data); err != nil {
+ log.Printf("engine: parse FAILED: %v", err)
+ return fmt.Errorf("parse config: %w", err)
+ }
+
+ boxCtx, cancel := context.WithCancel(ctx)
+ e.cancel = cancel
+
+ instance, err := box.New(box.Options{
+ Context: boxCtx,
+ Options: opts,
+ })
+ if err != nil {
+ cancel()
+ log.Printf("engine: create FAILED: %v", err)
+ return fmt.Errorf("create sing-box: %w", err)
+ }
+
+ if err := instance.Start(); err != nil {
+ instance.Close()
+ cancel()
+ log.Printf("engine: start FAILED: %v", err)
+ return fmt.Errorf("start sing-box: %w", err)
+ }
+
+ e.instance = instance
+ e.running = true
+ e.localProxyPort = localProxyPort
+ log.Println("engine: started ok")
+ return nil
+}
+
+func (e *Engine) Stop() error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+
+ if !e.running {
+ return nil
+ }
+
+ if e.instance != nil {
+ e.instance.Close()
+ e.instance = nil
+ }
+ if e.cancel != nil {
+ e.cancel()
+ }
+ e.running = false
+ e.localProxyPort = 0
+ log.Println("engine: stopped")
+ return nil
+}
+
+func (e *Engine) Restart(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error {
+ e.Stop()
+ return e.Start(server, mode, ruleSets, serverIPs)
+}
+
+func (e *Engine) RestartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) error {
+ e.Stop()
+ return e.StartFull(server, mode, ruleSets, serverIPs, customBypass, localProxyPort, policy)
+}
+
+func (e *Engine) IsRunning() bool {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return e.running
+}
+
+func (e *Engine) ConfigPath() string {
+ return e.configPath
+}
+
+func (e *Engine) LocalProxyPort() int {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ return e.localProxyPort
+}
diff --git a/internal/engine/healthcheck.go b/internal/engine/healthcheck.go
new file mode 100644
index 0000000..a856608
--- /dev/null
+++ b/internal/engine/healthcheck.go
@@ -0,0 +1,63 @@
+package engine
+
+import (
+ "io"
+ "net/http"
+ "strings"
+ "time"
+
+ "vpnem/internal/config"
+)
+
+const DefaultBlockedSiteProbeURL = "https://rutracker.org"
+
+func ModeRequiresExitIP(mode config.Mode) bool {
+ return mode.Final == "proxy"
+}
+
+func CheckExitIP(localProxyPort int) string {
+ client, err := HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 5*time.Second)
+ if err != nil {
+ return ""
+ }
+ resp, err := client.Get("http://ifconfig.me/ip")
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+
+ body, err := io.ReadAll(io.LimitReader(resp.Body, 64))
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(body))
+}
+
+func ProbeBlockedSite(localProxyPort int, rawURL string, timeout time.Duration) (int, error) {
+ client, err := HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, timeout)
+ if err != nil {
+ return 0, err
+ }
+
+ req, err := http.NewRequest(http.MethodGet, rawURL, nil)
+ if err != nil {
+ return 0, err
+ }
+ req.Header.Set("User-Agent", "vpnem-health/1.0")
+
+ resp, err := client.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ defer resp.Body.Close()
+
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 256))
+ return resp.StatusCode, nil
+}
+
+func DeepCheckRequiresRestart(mode config.Mode, exitIP string, probeErr error) bool {
+ if ModeRequiresExitIP(mode) {
+ return exitIP == ""
+ }
+ return probeErr != nil
+}
diff --git a/internal/engine/healthcheck_test.go b/internal/engine/healthcheck_test.go
new file mode 100644
index 0000000..1d55ed0
--- /dev/null
+++ b/internal/engine/healthcheck_test.go
@@ -0,0 +1,38 @@
+package engine
+
+import (
+ "errors"
+ "testing"
+
+ "vpnem/internal/config"
+)
+
+func TestModeRequiresExitIP(t *testing.T) {
+ proxyMode := config.Mode{Name: "Full", Final: "proxy"}
+ directMode := config.Mode{Name: "Combo", Final: "direct"}
+
+ if !ModeRequiresExitIP(proxyMode) {
+ t.Fatal("expected proxy-final mode to require exit IP")
+ }
+ if ModeRequiresExitIP(directMode) {
+ t.Fatal("did not expect direct-final mode to require exit IP")
+ }
+}
+
+func TestDeepCheckRequiresRestart(t *testing.T) {
+ proxyMode := config.Mode{Name: "Full", Final: "proxy"}
+ directMode := config.Mode{Name: "Combo", Final: "direct"}
+
+ if !DeepCheckRequiresRestart(proxyMode, "", nil) {
+ t.Fatal("expected proxy-final mode without exit IP to restart")
+ }
+ if DeepCheckRequiresRestart(proxyMode, "89.124.96.166", errors.New("probe failed")) {
+ t.Fatal("did not expect proxy-final mode with exit IP to restart")
+ }
+ if !DeepCheckRequiresRestart(directMode, "", errors.New("probe failed")) {
+ t.Fatal("expected direct-final mode with failed blocked probe to restart")
+ }
+ if DeepCheckRequiresRestart(directMode, "", nil) {
+ t.Fatal("did not expect direct-final mode with successful blocked probe to restart")
+ }
+}
diff --git a/internal/engine/httpclient.go b/internal/engine/httpclient.go
new file mode 100644
index 0000000..e491cf5
--- /dev/null
+++ b/internal/engine/httpclient.go
@@ -0,0 +1,45 @@
+package engine
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "net/http"
+ "time"
+
+ "golang.org/x/net/proxy"
+)
+
+func HTTPClientViaSOCKS5(host string, port int, timeout time.Duration) (*http.Client, error) {
+ if host == "" || port <= 0 {
+ return nil, fmt.Errorf("invalid local socks5 endpoint")
+ }
+
+ addr := fmt.Sprintf("%s:%d", host, port)
+ dialer, err := proxy.SOCKS5("tcp", addr, nil, proxy.Direct)
+ if err != nil {
+ return nil, err
+ }
+
+ contextDialer, ok := dialer.(proxy.ContextDialer)
+ if !ok {
+ return nil, fmt.Errorf("socks5 dialer does not implement context dialing")
+ }
+
+ transport := &http.Transport{
+ DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
+ return contextDialer.DialContext(ctx, network, address)
+ },
+ ForceAttemptHTTP2: true,
+ MaxIdleConns: 10,
+ IdleConnTimeout: 30 * time.Second,
+ TLSHandshakeTimeout: timeout,
+ ResponseHeaderTimeout: timeout,
+ ExpectContinueTimeout: time.Second,
+ }
+
+ return &http.Client{
+ Timeout: timeout,
+ Transport: transport,
+ }, nil
+}
diff --git a/internal/engine/logger.go b/internal/engine/logger.go
new file mode 100644
index 0000000..c448a56
--- /dev/null
+++ b/internal/engine/logger.go
@@ -0,0 +1,62 @@
+package engine
+
+import (
+ "os"
+ "path/filepath"
+ "sync"
+)
+
+// RingLog keeps last N log lines in memory and optionally writes to file.
+type RingLog struct {
+ mu sync.Mutex
+ lines []string
+ max int
+ file *os.File
+}
+
+// NewRingLog creates a ring buffer logger.
+func NewRingLog(maxLines int, dataDir string) *RingLog {
+ rl := &RingLog{
+ lines: make([]string, 0, maxLines),
+ max: maxLines,
+ }
+ if dataDir != "" {
+ f, err := os.OpenFile(filepath.Join(dataDir, "vpnem.log"),
+ os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
+ if err == nil {
+ rl.file = f
+ }
+ }
+ return rl
+}
+
+// Add appends a line.
+func (rl *RingLog) Add(line string) {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+
+ if len(rl.lines) >= rl.max {
+ rl.lines = rl.lines[1:]
+ }
+ rl.lines = append(rl.lines, line)
+
+ if rl.file != nil {
+ rl.file.WriteString(line + "\n")
+ }
+}
+
+// Lines returns all current lines.
+func (rl *RingLog) Lines() []string {
+ rl.mu.Lock()
+ defer rl.mu.Unlock()
+ cp := make([]string, len(rl.lines))
+ copy(cp, rl.lines)
+ return cp
+}
+
+// Close closes the log file.
+func (rl *RingLog) Close() {
+ if rl.file != nil {
+ rl.file.Close()
+ }
+}
diff --git a/internal/engine/proxy_port.go b/internal/engine/proxy_port.go
new file mode 100644
index 0000000..5e657c0
--- /dev/null
+++ b/internal/engine/proxy_port.go
@@ -0,0 +1,37 @@
+package engine
+
+import (
+ "fmt"
+ "net"
+
+ "vpnem/internal/config"
+)
+
+var localProxyPortCandidates = []int{config.LocalProxyPort, 10808, 10880, 18080, 20800}
+
+func ResolveLocalProxyPort() (int, error) {
+ for _, port := range localProxyPortCandidates {
+ if localProxyPortAvailable(port) {
+ return port, nil
+ }
+ }
+ listener, err := net.Listen("tcp", net.JoinHostPort(config.LocalProxyHost, "0"))
+ if err != nil {
+ return 0, err
+ }
+ defer listener.Close()
+ addr, ok := listener.Addr().(*net.TCPAddr)
+ if !ok {
+ return 0, fmt.Errorf("unexpected listener addr type %T", listener.Addr())
+ }
+ return addr.Port, nil
+}
+
+func localProxyPortAvailable(port int) bool {
+ listener, err := net.Listen("tcp", net.JoinHostPort(config.LocalProxyHost, fmt.Sprintf("%d", port)))
+ if err != nil {
+ return false
+ }
+ _ = listener.Close()
+ return true
+}
diff --git a/internal/engine/watchdog.go b/internal/engine/watchdog.go
new file mode 100644
index 0000000..a27945f
--- /dev/null
+++ b/internal/engine/watchdog.go
@@ -0,0 +1,147 @@
+package engine
+
+import (
+ "context"
+ "log"
+ "time"
+
+ "vpnem/internal/config"
+ "vpnem/internal/models"
+)
+
+// WatchdogConfig holds watchdog parameters.
+type WatchdogConfig struct {
+ CheckInterval time.Duration // how often to check sing-box is alive (default 2s)
+ DeepCheckInterval time.Duration // how often to verify exit IP (default 30s)
+ ReconnectCooldown time.Duration // min time between reconnect attempts (default 5s)
+}
+
+// DefaultWatchdogConfig returns the default watchdog settings (from vpn.py).
+func DefaultWatchdogConfig() WatchdogConfig {
+ return WatchdogConfig{
+ CheckInterval: 2 * time.Second,
+ DeepCheckInterval: 30 * time.Second,
+ ReconnectCooldown: 5 * time.Second,
+ }
+}
+
+// Watchdog monitors sing-box and auto-reconnects on failure.
+type Watchdog struct {
+ engine *Engine
+ cfg WatchdogConfig
+ cancel context.CancelFunc
+ running bool
+
+ // Reconnect parameters (set via StartWatching)
+ server models.Server
+ mode config.Mode
+ ruleSets []models.RuleSet
+ serverIPs []string
+ customBypass []string
+ localProxyPort int
+ policy *models.RoutingPolicy
+}
+
+// NewWatchdog creates a new watchdog for the given engine.
+func NewWatchdog(engine *Engine, cfg WatchdogConfig) *Watchdog {
+ return &Watchdog{
+ engine: engine,
+ cfg: cfg,
+ }
+}
+
+// StartWatching begins monitoring. It stores the connection params for reconnection.
+func (w *Watchdog) StartWatching(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string, localProxyPort int, policy *models.RoutingPolicy) {
+ w.StopWatching()
+
+ w.server = server
+ w.mode = mode
+ w.ruleSets = ruleSets
+ w.serverIPs = serverIPs
+ w.customBypass = append([]string{}, customBypass...)
+ w.localProxyPort = localProxyPort
+ w.policy = policy
+
+ ctx, cancel := context.WithCancel(context.Background())
+ w.cancel = cancel
+ w.running = true
+
+ go w.loop(ctx)
+}
+
+// StopWatching stops the watchdog.
+func (w *Watchdog) StopWatching() {
+ if w.cancel != nil {
+ w.cancel()
+ }
+ w.running = false
+}
+
+// IsWatching returns whether the watchdog is active.
+func (w *Watchdog) IsWatching() bool {
+ return w.running
+}
+
+func (w *Watchdog) loop(ctx context.Context) {
+ ticker := time.NewTicker(w.cfg.CheckInterval)
+ defer ticker.Stop()
+
+ deepTicker := time.NewTicker(w.cfg.DeepCheckInterval)
+ defer deepTicker.Stop()
+
+ lastReconnect := time.Time{}
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+
+ case <-ticker.C:
+ if !w.engine.IsRunning() {
+ if time.Since(lastReconnect) < w.cfg.ReconnectCooldown {
+ continue
+ }
+ localProxyPort, err := ResolveLocalProxyPort()
+ if err != nil {
+ log.Printf("watchdog: local proxy port selection failed: %v", err)
+ continue
+ }
+ w.localProxyPort = localProxyPort
+ log.Println("watchdog: sing-box not running, reconnecting...")
+ if err := w.engine.StartFull(w.server, w.mode, w.ruleSets, w.serverIPs, w.customBypass, w.localProxyPort, w.policy); err != nil {
+ log.Printf("watchdog: reconnect failed: %v", err)
+ } else {
+ log.Println("watchdog: reconnected successfully")
+ }
+ lastReconnect = time.Now()
+ }
+
+ case <-deepTicker.C:
+ if !w.engine.IsRunning() {
+ continue
+ }
+ exitIP := CheckExitIP(w.localProxyPort)
+ _, probeErr := ProbeBlockedSite(w.localProxyPort, DefaultBlockedSiteProbeURL, 8*time.Second)
+ if DeepCheckRequiresRestart(w.mode, exitIP, probeErr) {
+ if ModeRequiresExitIP(w.mode) {
+ log.Println("watchdog: deep check failed (no exit IP), restarting...")
+ } else {
+ log.Printf("watchdog: deep check failed for direct-final mode (%v), restarting...", probeErr)
+ }
+ if time.Since(lastReconnect) < w.cfg.ReconnectCooldown {
+ continue
+ }
+ localProxyPort, err := ResolveLocalProxyPort()
+ if err != nil {
+ log.Printf("watchdog: local proxy port selection failed: %v", err)
+ continue
+ }
+ w.localProxyPort = localProxyPort
+ if err := w.engine.RestartFull(w.server, w.mode, w.ruleSets, w.serverIPs, w.customBypass, w.localProxyPort, w.policy); err != nil {
+ log.Printf("watchdog: restart failed: %v", err)
+ }
+ lastReconnect = time.Now()
+ }
+ }
+ }
+}
diff --git a/internal/models/catalog.go b/internal/models/catalog.go
new file mode 100644
index 0000000..eda61cd
--- /dev/null
+++ b/internal/models/catalog.go
@@ -0,0 +1,35 @@
+package models
+
+type CatalogV2 struct {
+ Version string `json:"version"`
+ Nodes []CatalogNode `json:"nodes"`
+}
+
+type CatalogNode struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Provider string `json:"provider,omitempty"`
+ Region string `json:"region"`
+ Host string `json:"host"`
+ Domain string `json:"domain,omitempty"`
+ PublicHost string `json:"public_host"`
+ Protocols []CatalogProtocol `json:"protocols"`
+ Status string `json:"status,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Metadata map[string]any `json:"metadata,omitempty"`
+}
+
+type CatalogProtocol struct {
+ Type string `json:"type"`
+ Enabled bool `json:"enabled"`
+ Port int `json:"port"`
+ TLS *TLS `json:"tls,omitempty"`
+ Auth *CatalogAuth `json:"auth,omitempty"`
+ Extra map[string]any `json:"extra,omitempty"`
+}
+
+type CatalogAuth struct {
+ UUID string `json:"uuid,omitempty"`
+ Method string `json:"method,omitempty"`
+ Password string `json:"password,omitempty"`
+}
diff --git a/internal/models/client.go b/internal/models/client.go
new file mode 100644
index 0000000..f086594
--- /dev/null
+++ b/internal/models/client.go
@@ -0,0 +1,59 @@
+package models
+
+import "time"
+
+// ActiveSession tracks a currently active VPN connection.
+type ActiveSession struct {
+ ClientIP string `json:"client_ip"` // real public IP of client (from X-Forwarded-For)
+ ServerIP string `json:"server_ip"` // VPN server IP they connected to
+ NodeID string `json:"node_id"` // catalog node ID
+ OS string `json:"os"`
+ Version string `json:"version"`
+ ConnectedAt time.Time `json:"connected_at"`
+ LastHeartbeat time.Time `json:"last_seen"`
+}
+
+// StudioRecord tracks a studio's home server assignment.
+// A studio = all clients sharing the same public IP.
+type StudioRecord struct {
+ ClientIP string `json:"client_ip"` // public IP = studio identifier
+ HomeServerIP string `json:"home_server_ip"` // assigned "home" server
+ HomeNodeID string `json:"home_node_id"`
+ HomeAssignedAt time.Time `json:"home_assigned_at"` // when home was assigned
+ TotalClients int `json:"total_clients"` // lifetime client count from this studio
+ LastSeen time.Time `json:"last_seen"`
+}
+
+// ConnectRequest is sent when a client connects.
+// Server determines client_ip from X-Forwarded-For — no client_ip field needed.
+type ConnectRequest struct {
+ ServerIP string `json:"server_ip"`
+ NodeID string `json:"node_id"`
+ OS string `json:"os"`
+ Version string `json:"version"`
+}
+
+// DisconnectRequest is sent when a client disconnects.
+type DisconnectRequest struct {
+ ServerIP string `json:"server_ip"`
+ NodeID string `json:"node_id"`
+}
+
+// RecommendationResponse is returned by the recommendation endpoint.
+type RecommendationResponse struct {
+ RecommendedServerIP string `json:"recommended_server_ip"`
+ RecommendedNodeID string `json:"recommended_node_id"`
+ RecommendedTag string `json:"recommended_tag,omitempty"`
+ Reason string `json:"reason"`
+ IsRebalance bool `json:"is_rebalance"` // true if recommending different server than home
+ LoadInfo string `json:"load_info"` // human-readable load summary
+ StudioClients int `json:"studio_clients"` // active clients from same studio
+}
+
+// ServerLoadInfo contains load data for all servers.
+type ServerLoadInfo struct {
+ ServerIP string `json:"server_ip"`
+ ActiveClients int `json:"active_clients"`
+ LoadPercent int `json:"load_percent"` // 0-100
+ MaxCapacity int `json:"max_capacity"`
+}
diff --git a/internal/models/policy.go b/internal/models/policy.go
new file mode 100644
index 0000000..09e15e1
--- /dev/null
+++ b/internal/models/policy.go
@@ -0,0 +1,23 @@
+package models
+
+type RoutingPolicy struct {
+ Version string `json:"version"`
+ AlwaysDirectProcesses []string `json:"always_direct_processes,omitempty"`
+ PreferDirectProcesses []string `json:"prefer_direct_processes,omitempty"`
+ ProxyableBrowserProcesses []string `json:"proxyable_browser_processes,omitempty"`
+ LovenseProcessRegex []string `json:"lovense_process_regex,omitempty"`
+ StaticBypassIPs []string `json:"static_bypass_ips,omitempty"`
+ ReservedCIDRs []string `json:"reserved_cidrs,omitempty"`
+ LocalDomainSuffixes []string `json:"local_domain_suffixes,omitempty"`
+ WindowsNCSIDomains []string `json:"windows_ncsi_domains,omitempty"`
+ InfraBypassDomains []string `json:"infra_bypass_domains,omitempty"`
+ ForcedProxyIPs []string `json:"forced_proxy_ips,omitempty"`
+ TelegramProcesses []string `json:"telegram_processes,omitempty"`
+ TelegramProcessRegex []string `json:"telegram_process_regex,omitempty"`
+ TelegramDomains []string `json:"telegram_domains,omitempty"`
+ TelegramDomainRegex []string `json:"telegram_domain_regex,omitempty"`
+ TelegramIPs []string `json:"telegram_ips,omitempty"`
+ BlockedDomains []string `json:"blocked_domains,omitempty"`
+ ProxyDNSDomains []string `json:"proxy_dns_domains,omitempty"`
+ IPCheckDomains []string `json:"ip_check_domains,omitempty"`
+}
diff --git a/internal/models/ruleset.go b/internal/models/ruleset.go
new file mode 100644
index 0000000..8599322
--- /dev/null
+++ b/internal/models/ruleset.go
@@ -0,0 +1,23 @@
+package models
+
+type RuleSet struct {
+ Tag string `json:"tag"`
+ Description string `json:"description"`
+ URL string `json:"url"`
+ LocalPath string `json:"-"`
+ Format string `json:"format"` // binary, source
+ Type string `json:"type"` // domain, ip
+ Optional bool `json:"optional"`
+ SHA256 string `json:"sha256,omitempty"`
+}
+
+type RuleSetManifest struct {
+ RuleSets []RuleSet `json:"rule_sets"`
+}
+
+type VersionResponse struct {
+ Version string `json:"version"`
+ URL string `json:"url"`
+ SHA256 string `json:"sha256,omitempty"`
+ Changelog string `json:"changelog,omitempty"`
+}
diff --git a/internal/models/server.go b/internal/models/server.go
new file mode 100644
index 0000000..f440c03
--- /dev/null
+++ b/internal/models/server.go
@@ -0,0 +1,46 @@
+package models
+
+type TLS struct {
+ Enabled bool `json:"enabled"`
+ ServerName string `json:"server_name,omitempty"`
+ Insecure bool `json:"insecure,omitempty"`
+ ALPN []string `json:"alpn,omitempty"`
+ MinVersion string `json:"min_version,omitempty"`
+ MaxVersion string `json:"max_version,omitempty"`
+ Reality *Reality `json:"reality,omitempty"`
+}
+
+type Transport struct {
+ Type string `json:"type,omitempty"`
+ Path string `json:"path,omitempty"`
+}
+
+type Reality struct {
+ Enabled bool `json:"enabled,omitempty"`
+ PublicKey string `json:"public_key,omitempty"`
+ PrivateKey string `json:"private_key,omitempty"`
+ ShortID string `json:"short_id,omitempty"`
+ Fingerprint string `json:"fingerprint,omitempty"`
+}
+
+type Server struct {
+ Tag string `json:"tag"`
+ Region string `json:"region"`
+ Type string `json:"type"` // socks, vless, vless-reality, shadowsocks, vmess, hysteria2
+ Server string `json:"server"`
+ ServerPort int `json:"server_port"`
+ UDPOverTCP bool `json:"udp_over_tcp,omitempty"`
+ UUID string `json:"uuid,omitempty"`
+ Method string `json:"method,omitempty"`
+ Password string `json:"password,omitempty"`
+ ObfsPassword string `json:"obfs_password,omitempty"`
+ UpMbps int `json:"up_mbps,omitempty"`
+ DownMbps int `json:"down_mbps,omitempty"`
+ TLS *TLS `json:"tls,omitempty"`
+ Transport *Transport `json:"transport,omitempty"`
+ Companions []Server `json:"companions,omitempty"`
+}
+
+type ServersResponse struct {
+ Servers []Server `json:"servers"`
+}
diff --git a/internal/rules/connections.go b/internal/rules/connections.go
new file mode 100644
index 0000000..705bbf5
--- /dev/null
+++ b/internal/rules/connections.go
@@ -0,0 +1,336 @@
+package rules
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+
+ "vpnem/internal/models"
+)
+
+const (
+ sessionExpiry = 1 * time.Hour // session considered stale after 1h
+ studioExpiry = 7 * 24 * time.Hour // studio record kept for 7 days
+ defaultMaxCap = 50 // default max clients per server
+)
+
+// ConnectionStore manages active sessions and studio assignments.
+type ConnectionStore struct {
+ mu sync.RWMutex
+ path string
+ sessions map[string]*models.ActiveSession // key: client_ip (one active session per studio)
+ studios map[string]*models.StudioRecord // key: client_ip
+ maxCap int
+ staleAfter time.Duration
+}
+
+// NewConnectionStore creates a store backed by a JSON file.
+func NewConnectionStore(dataDir string) *ConnectionStore {
+ return &ConnectionStore{
+ path: filepath.Join(dataDir, "connections.json"),
+ sessions: make(map[string]*models.ActiveSession),
+ studios: make(map[string]*models.StudioRecord),
+ maxCap: defaultMaxCap,
+ staleAfter: sessionExpiry,
+ }
+}
+
+// Load reads connections from disk.
+func (s *ConnectionStore) Load() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ data, err := os.ReadFile(s.path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+
+ var store struct {
+ Sessions map[string]*models.ActiveSession `json:"sessions"`
+ Studios map[string]*models.StudioRecord `json:"studios"`
+ }
+ if err := json.Unmarshal(data, &store); err != nil {
+ return err
+ }
+
+ s.sessions = store.Sessions
+ if s.sessions == nil {
+ s.sessions = make(map[string]*models.ActiveSession)
+ }
+ s.studios = store.Studios
+ if s.studios == nil {
+ s.studios = make(map[string]*models.StudioRecord)
+ }
+
+ s.expireStaleLocked()
+ return s.saveLocked()
+}
+
+// Connect records a new active session.
+func (s *ConnectionStore) Connect(clientIP, serverIP, nodeID, osName, version string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ now := time.Now()
+
+ // Update or create active session
+ s.sessions[clientIP] = &models.ActiveSession{
+ ClientIP: clientIP,
+ ServerIP: serverIP,
+ NodeID: nodeID,
+ OS: osName,
+ Version: version,
+ ConnectedAt: now,
+ LastHeartbeat: now,
+ }
+
+ // Update or create studio record
+ studio, exists := s.studios[clientIP]
+ if !exists {
+ studio = &models.StudioRecord{
+ ClientIP: clientIP,
+ HomeServerIP: serverIP,
+ HomeNodeID: nodeID,
+ HomeAssignedAt: now,
+ LastSeen: now,
+ }
+ s.studios[clientIP] = studio
+ }
+ studio.LastSeen = now
+ studio.TotalClients++
+
+ // If studio has no home yet, assign one
+ if studio.HomeServerIP == "" {
+ studio.HomeServerIP = serverIP
+ studio.HomeNodeID = nodeID
+ studio.HomeAssignedAt = now
+ }
+
+ s.expireStaleLocked()
+ _ = s.saveLocked()
+}
+
+// Disconnect marks a session as inactive.
+func (s *ConnectionStore) Disconnect(clientIP string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ delete(s.sessions, clientIP)
+ _ = s.saveLocked()
+}
+
+// Heartbeat updates the last-seen time for a session.
+func (s *ConnectionStore) Heartbeat(clientIP string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if sess, ok := s.sessions[clientIP]; ok {
+ sess.LastHeartbeat = time.Now()
+ }
+ if studio, ok := s.studios[clientIP]; ok {
+ studio.LastSeen = time.Now()
+ }
+}
+
+// GetRecommendation returns the recommended server for a client IP.
+// Pure load-based: always picks the least loaded available + healthy server.
+// No sticky home — auto-balancing on every request.
+func (s *ConnectionStore) GetRecommendation(clientIP string, availableIPs []string, healthyIPs map[string]bool) models.RecommendationResponse {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ resp := models.RecommendationResponse{}
+
+ // Count active connections per server
+ load := s.activeLoadLocked(availableIPs)
+
+ // Always pick least loaded — no sticky
+ bestIP := s.findLeastLoadedLocked(availableIPs, load, healthyIPs)
+ if bestIP == "" {
+ resp.Reason = "нет доступных серверов"
+ return resp
+ }
+
+ resp.RecommendedServerIP = bestIP
+ resp.LoadInfo = s.formatLoadInfo(load)
+
+ // Count how many clients on this IP
+ resp.StudioClients = load[bestIP]
+
+ // Check if this is the same as the studio's previous choice
+ studio, hasStudio := s.studios[clientIP]
+ if hasStudio && studio.HomeServerIP == bestIP {
+ resp.Reason = "рекомендуемый сервер"
+ } else {
+ resp.Reason = "наименее загружен"
+ resp.IsRebalance = hasStudio && studio.HomeServerIP != ""
+ }
+
+ return resp
+}
+
+// GetLoadInfo returns load information for all available servers.
+func (s *ConnectionStore) GetLoadInfo(availableIPs []string) []models.ServerLoadInfo {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+
+ load := s.activeLoadLocked(availableIPs)
+ var infos []models.ServerLoadInfo
+
+ for _, ip := range availableIPs {
+ clients := load[ip]
+ pct := 0
+ if s.maxCap > 0 {
+ pct = (clients * 100) / s.maxCap
+ }
+ infos = append(infos, models.ServerLoadInfo{
+ ServerIP: ip,
+ ActiveClients: clients,
+ LoadPercent: pct,
+ MaxCapacity: s.maxCap,
+ })
+ }
+
+ return infos
+}
+
+// activeLoadLocked counts active sessions per server IP. Must be called with lock held.
+func (s *ConnectionStore) activeLoadLocked(availableIPs []string) map[string]int {
+ load := make(map[string]int)
+ for _, ip := range availableIPs {
+ load[ip] = 0
+ }
+ now := time.Now()
+ for _, sess := range s.sessions {
+ if now.Sub(sess.LastHeartbeat) < s.staleAfter {
+ load[sess.ServerIP]++
+ }
+ }
+ return load
+}
+
+// findLeastLoadedLocked finds the least loaded available + healthy server.
+func (s *ConnectionStore) findLeastLoadedLocked(availableIPs []string, load map[string]int, healthyIPs map[string]bool) string {
+ type ipLoad struct {
+ ip string
+ count int
+ }
+ var candidates []ipLoad
+
+ for _, ip := range availableIPs {
+ if len(healthyIPs) > 0 && !healthyIPs[ip] {
+ continue
+ }
+ candidates = append(candidates, ipLoad{ip, load[ip]})
+ }
+
+ if len(candidates) == 0 {
+ return ""
+ }
+
+ sort.Slice(candidates, func(i, j int) bool {
+ return candidates[i].count < candidates[j].count
+ })
+
+ return candidates[0].ip
+}
+
+// expireStaleLocked removes stale sessions and old studio records.
+func (s *ConnectionStore) expireStaleLocked() {
+ now := time.Now()
+
+ // Expire stale sessions
+ for key, sess := range s.sessions {
+ if now.Sub(sess.LastHeartbeat) > s.staleAfter {
+ delete(s.sessions, key)
+ }
+ }
+
+ // Expire old studio records (kept for reference)
+ for key, studio := range s.studios {
+ if now.Sub(studio.LastSeen) > studioExpiry {
+ delete(s.studios, key)
+ }
+ }
+}
+
+// saveLocked writes state to disk.
+func (s *ConnectionStore) saveLocked() error {
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
+ return err
+ }
+
+ store := struct {
+ Sessions map[string]*models.ActiveSession `json:"sessions"`
+ Studios map[string]*models.StudioRecord `json:"studios"`
+ }{
+ Sessions: s.sessions,
+ Studios: s.studios,
+ }
+
+ data, err := json.MarshalIndent(store, "", " ")
+ if err != nil {
+ return err
+ }
+
+ tmpPath := s.path + ".tmp"
+ if err := os.WriteFile(tmpPath, data, 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmpPath, s.path)
+}
+
+func (s *ConnectionStore) formatLoadInfo(load map[string]int) string {
+ var parts []string
+ // Sort for consistent output
+ var ips []string
+ for ip := range load {
+ ips = append(ips, ip)
+ }
+ sort.Strings(ips)
+
+ for _, ip := range ips {
+ parts = append(parts, ip+"="+itoaStr(load[ip]))
+ }
+ return "нагрузка: " + joinStr(parts, ", ")
+}
+
+// SetMaxCapacity sets the max clients per server for load calculation.
+func (s *ConnectionStore) SetMaxCapacity(n int) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if n > 0 {
+ s.maxCap = n
+ }
+}
+
+// itoaStr converts int to string without fmt.
+func itoaStr(n int) string {
+ if n == 0 {
+ return "0"
+ }
+ var digits []byte
+ for n > 0 {
+ digits = append([]byte{byte('0' + n%10)}, digits...)
+ n /= 10
+ }
+ return string(digits)
+}
+
+// joinStr joins strings with separator without strings import.
+func joinStr(parts []string, sep string) string {
+ if len(parts) == 0 {
+ return ""
+ }
+ result := parts[0]
+ for _, p := range parts[1:] {
+ result += sep + p
+ }
+ return result
+}
diff --git a/internal/rules/connections_test.go b/internal/rules/connections_test.go
new file mode 100644
index 0000000..22355dc
--- /dev/null
+++ b/internal/rules/connections_test.go
@@ -0,0 +1,201 @@
+package rules
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestConnectionStoreConnectAndRecommend(t *testing.T) {
+ tmpDir := t.TempDir()
+ store := NewConnectionStore(tmpDir)
+ if err := store.Load(); err != nil {
+ t.Fatal(err)
+ }
+
+ availableIPs := []string{"5.180.97.198", "5.180.97.199", "5.180.97.197"}
+ healthyIPs := map[string]bool{"5.180.97.198": true, "5.180.97.199": true, "5.180.97.197": true}
+
+ // Studio 1 connects to 198
+ store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "2.0.11")
+
+ // Studio 1 asks — should get 198 (least loaded: 1 client vs 0/0)
+ // Actually: 198=1, 199=0, 197=0 → should get 199 or 197 (least loaded)
+ rec1 := store.GetRecommendation("1.2.3.4", availableIPs, healthyIPs)
+ if rec1.RecommendedServerIP == "5.180.97.198" {
+ // This is OK if load balancing picks a different server
+ t.Logf("studio 1 recommended: %s (reason: %s)", rec1.RecommendedServerIP, rec1.Reason)
+ } else {
+ t.Logf("studio 1 recommended different server: %s (load-based)", rec1.RecommendedServerIP)
+ }
+
+ // Studio 2 is new — should also get least loaded
+ rec2 := store.GetRecommendation("9.9.9.9", availableIPs, healthyIPs)
+ if rec2.RecommendedServerIP == "" {
+ t.Fatal("expected recommendation for new studio")
+ }
+ t.Logf("studio 2 recommended: %s (reason: %s)", rec2.RecommendedServerIP, rec2.Reason)
+}
+
+func TestPureLoadBalancing(t *testing.T) {
+ tmpDir := t.TempDir()
+ store := NewConnectionStore(tmpDir)
+ if err := store.Load(); err != nil {
+ t.Fatal(err)
+ }
+
+ availableIPs := []string{"5.180.97.198", "5.180.97.199", "5.180.97.197"}
+ healthyIPs := map[string]bool{"5.180.97.198": true, "5.180.97.199": true, "5.180.97.197": true}
+
+ // 3 studios connect to 198 (overload it)
+ for i := 0; i < 3; i++ {
+ ip := "10.0.0." + string(rune('1'+i))
+ store.Connect(ip, "5.180.97.198", "nl-198", "windows", "")
+ }
+
+ // New studio should NOT get 198 (3 clients) — should get 199 or 197 (0 clients)
+ rec := store.GetRecommendation("99.99.99.99", availableIPs, healthyIPs)
+ if rec.RecommendedServerIP == "5.180.97.198" {
+ t.Fatalf("should not recommend overloaded server, got %s", rec.RecommendedServerIP)
+ }
+ t.Logf("new studio recommended: %s (reason: %s)", rec.RecommendedServerIP, rec.Reason)
+
+ // Even studio 1 (home=198) should get load-balanced recommendation
+ rec2 := store.GetRecommendation("10.0.0.1", availableIPs, healthyIPs)
+ t.Logf("studio 1 re-recommended: %s (reason: %s, isRebalance: %v)",
+ rec2.RecommendedServerIP, rec2.Reason, rec2.IsRebalance)
+}
+
+func TestHomeServerUnhealthy(t *testing.T) {
+ tmpDir := t.TempDir()
+ store := NewConnectionStore(tmpDir)
+ if err := store.Load(); err != nil {
+ t.Fatal(err)
+ }
+
+ availableIPs := []string{"5.180.97.198", "5.180.97.199"}
+ // 198 is NOT healthy
+ healthyIPs := map[string]bool{"5.180.97.199": true}
+
+ // Studio 1 has connected to 198
+ store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "")
+
+ // But 198 is unhealthy — should recommend 199
+ rec := store.GetRecommendation("1.2.3.4", availableIPs, healthyIPs)
+ if rec.RecommendedServerIP == "5.180.97.198" {
+ t.Fatalf("should not recommend unhealthy server, got %s", rec.RecommendedServerIP)
+ }
+ if rec.RecommendedServerIP != "5.180.97.199" {
+ t.Fatalf("should recommend healthy server 199, got %s", rec.RecommendedServerIP)
+ }
+}
+
+func TestDisconnect(t *testing.T) {
+ tmpDir := t.TempDir()
+ store := NewConnectionStore(tmpDir)
+ if err := store.Load(); err != nil {
+ t.Fatal(err)
+ }
+
+ availableIPs := []string{"5.180.97.198"}
+
+ store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "")
+
+ load := store.GetLoadInfo(availableIPs)
+ if len(load) == 0 || load[0].ActiveClients != 1 {
+ t.Fatalf("expected 1 active client, got %v", load)
+ }
+
+ store.Disconnect("1.2.3.4")
+
+ load = store.GetLoadInfo(availableIPs)
+ if len(load) == 0 || load[0].ActiveClients != 0 {
+ t.Fatalf("expected 0 active clients after disconnect, got %v", load)
+ }
+}
+
+func TestSessionExpiry(t *testing.T) {
+ tmpDir := t.TempDir()
+ store := NewConnectionStore(tmpDir)
+ store.staleAfter = 1 * time.Millisecond
+ if err := store.Load(); err != nil {
+ t.Fatal(err)
+ }
+
+ availableIPs := []string{"5.180.97.198"}
+ healthyIPs := map[string]bool{"5.180.97.198": true}
+
+ store.Connect("1.2.3.4", "5.180.97.198", "nl-198", "windows", "")
+ time.Sleep(10 * time.Millisecond)
+
+ rec := store.GetRecommendation("1.2.3.4", availableIPs, healthyIPs)
+ if rec.RecommendedServerIP != "5.180.97.198" {
+ t.Fatalf("expected recommendation to 198 after session expiry, got %s", rec.RecommendedServerIP)
+ }
+
+ load := store.GetLoadInfo(availableIPs)
+ if len(load) == 0 || load[0].ActiveClients != 0 {
+ t.Fatalf("expected 0 active clients after expiry, got %v", load)
+ }
+}
+
+func TestPersistence(t *testing.T) {
+ tmpDir := t.TempDir()
+
+ store1 := NewConnectionStore(tmpDir)
+ store1.Connect("1.2.3.4", "5.180.97.199", "nl-199", "windows", "")
+
+ store2 := NewConnectionStore(tmpDir)
+ if err := store2.Load(); err != nil {
+ t.Fatal(err)
+ }
+
+ availableIPs := []string{"5.180.97.199"}
+ healthyIPs := map[string]bool{"5.180.97.199": true}
+ rec := store2.GetRecommendation("1.2.3.4", availableIPs, healthyIPs)
+ if rec.RecommendedServerIP != "5.180.97.199" {
+ t.Fatalf("expected recommendation to 199, got %s", rec.RecommendedServerIP)
+ }
+
+ _, err := os.Stat(filepath.Join(tmpDir, "connections.json"))
+ if err != nil {
+ t.Fatal("expected connections.json to exist")
+ }
+}
+
+func TestLoadInfoFormat(t *testing.T) {
+ tmpDir := t.TempDir()
+ store := NewConnectionStore(tmpDir)
+ store.maxCap = 10
+
+ store.Connect("1.1.1.1", "5.180.97.198", "nl-198", "windows", "")
+ store.Connect("2.2.2.2", "5.180.97.198", "nl-198", "windows", "")
+ store.Connect("3.3.3.3", "5.180.97.199", "nl-199", "linux", "")
+
+ availableIPs := []string{"5.180.97.198", "5.180.97.199"}
+ load := store.GetLoadInfo(availableIPs)
+
+ if len(load) != 2 {
+ t.Fatalf("expected 2 server load entries, got %d", len(load))
+ }
+
+ for _, info := range load {
+ if info.ServerIP == "5.180.97.198" {
+ if info.ActiveClients != 2 {
+ t.Errorf("expected 2 clients on 198, got %d", info.ActiveClients)
+ }
+ if info.LoadPercent != 20 {
+ t.Errorf("expected 20%% load on 198, got %d", info.LoadPercent)
+ }
+ }
+ if info.ServerIP == "5.180.97.199" {
+ if info.ActiveClients != 1 {
+ t.Errorf("expected 1 client on 199, got %d", info.ActiveClients)
+ }
+ if info.LoadPercent != 10 {
+ t.Errorf("expected 10%% load on 199, got %d", info.LoadPercent)
+ }
+ }
+ }
+}
diff --git a/internal/rules/loader.go b/internal/rules/loader.go
new file mode 100644
index 0000000..7bbbe6e
--- /dev/null
+++ b/internal/rules/loader.go
@@ -0,0 +1,210 @@
+package rules
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "vpnem/internal/models"
+)
+
+type Store struct {
+ dataDir string
+ connections *ConnectionStore
+}
+
+func NewStore(dataDir string) *Store {
+ s := &Store{
+ dataDir: dataDir,
+ connections: NewConnectionStore(dataDir),
+ }
+ _ = s.connections.Load()
+ return s
+}
+
+// Connections returns the connection store for recommendation logic.
+func (s *Store) Connections() *ConnectionStore {
+ return s.connections
+}
+
+func (s *Store) LoadServers() (*models.ServersResponse, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "servers.json"))
+ if err != nil {
+ return nil, err
+ }
+ var resp models.ServersResponse
+ if err := json.Unmarshal(data, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+func (s *Store) LoadRuleSets() (*models.RuleSetManifest, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "rulesets.json"))
+ if err != nil {
+ return nil, err
+ }
+ var manifest models.RuleSetManifest
+ if err := json.Unmarshal(data, &manifest); err != nil {
+ return nil, err
+ }
+ return &manifest, nil
+}
+
+func (s *Store) LoadVersion() (*models.VersionResponse, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "version.json"))
+ if err != nil {
+ return nil, err
+ }
+ var ver models.VersionResponse
+ if err := json.Unmarshal(data, &ver); err != nil {
+ return nil, err
+ }
+ return &ver, nil
+}
+
+func (s *Store) LoadCatalogV2() (*models.CatalogV2, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "catalog-v2.json"))
+ if err != nil {
+ return nil, err
+ }
+ var catalog models.CatalogV2
+ if err := json.Unmarshal(data, &catalog); err != nil {
+ return nil, err
+ }
+ return &catalog, nil
+}
+
+func (s *Store) LoadCatalogV2OrLegacy() (*models.CatalogV2, error) {
+ catalog, err := s.LoadCatalogV2()
+ if err == nil {
+ return catalog, nil
+ }
+ if !os.IsNotExist(err) {
+ return nil, err
+ }
+
+ servers, err := s.LoadServers()
+ if err != nil {
+ return nil, err
+ }
+ return legacyServersToCatalog(servers.Servers), nil
+}
+
+func (s *Store) LoadRoutingPolicy() (*models.RoutingPolicy, error) {
+ data, err := os.ReadFile(filepath.Join(s.dataDir, "routing-policy.json"))
+ if err != nil {
+ return nil, err
+ }
+ var policy models.RoutingPolicy
+ if err := json.Unmarshal(data, &policy); err != nil {
+ return nil, err
+ }
+ return &policy, nil
+}
+
+func (s *Store) RulesDir() string {
+ return filepath.Join(s.dataDir, "rules")
+}
+
+func (s *Store) ReleasesDir() string {
+ return filepath.Join(s.dataDir, "releases")
+}
+
+func (s *Store) DataDir() string {
+ return s.dataDir
+}
+
+func legacyServersToCatalog(servers []models.Server) *models.CatalogV2 {
+ nodesByID := make(map[string]*models.CatalogNode, len(servers))
+ order := make([]string, 0, len(servers))
+ for _, server := range servers {
+ nodeID := server.Tag
+ if existingID, protocolType, ok := splitLegacyTag(server.Tag); ok && existingID != "" {
+ nodeID = existingID
+ server.Type = protocolType
+ }
+
+ node := nodesByID[nodeID]
+ if node == nil {
+ node = &models.CatalogNode{
+ ID: nodeID,
+ Name: nodeID,
+ Region: server.Region,
+ Host: server.Server,
+ PublicHost: server.Server,
+ Status: "published",
+ }
+ nodesByID[nodeID] = node
+ order = append(order, nodeID)
+ }
+ node.Protocols = append(node.Protocols, legacyServerToCatalogProtocol(server))
+ }
+
+ nodes := make([]models.CatalogNode, 0, len(order))
+ for _, id := range order {
+ nodes = append(nodes, *nodesByID[id])
+ }
+ return &models.CatalogV2{
+ Version: "legacy-adapter",
+ Nodes: nodes,
+ }
+}
+
+func legacyServerToCatalogProtocol(server models.Server) models.CatalogProtocol {
+ protocolType := server.Type
+ if protocolType == "socks" {
+ protocolType = "socks5"
+ }
+ protocol := models.CatalogProtocol{
+ Type: protocolType,
+ Enabled: true,
+ Port: server.ServerPort,
+ TLS: server.TLS,
+ Extra: make(map[string]any),
+ }
+ switch server.Type {
+ case "vless", "vless-reality", "vmess":
+ protocol.Auth = &models.CatalogAuth{UUID: server.UUID}
+ protocol.Extra["legacy_tag"] = server.Tag
+ if server.Transport != nil {
+ protocol.Extra["transport_type"] = server.Transport.Type
+ if server.Transport.Path != "" {
+ protocol.Extra["path"] = server.Transport.Path
+ }
+ }
+ case "shadowsocks":
+ protocol.Auth = &models.CatalogAuth{Method: server.Method, Password: server.Password}
+ protocol.Extra["legacy_tag"] = server.Tag
+ case "hysteria2":
+ protocol.Auth = &models.CatalogAuth{Password: server.Password}
+ protocol.Extra["legacy_tag"] = server.Tag
+ if server.ObfsPassword != "" {
+ protocol.Extra["obfs_password"] = server.ObfsPassword
+ }
+ if server.UpMbps > 0 {
+ protocol.Extra["up_mbps"] = server.UpMbps
+ }
+ if server.DownMbps > 0 {
+ protocol.Extra["down_mbps"] = server.DownMbps
+ }
+ case "socks":
+ protocol.Extra["legacy_tag"] = server.Tag
+ protocol.Extra["udp_over_tcp"] = server.UDPOverTCP
+ }
+ if len(protocol.Extra) == 0 {
+ protocol.Extra = nil
+ }
+ return protocol
+}
+
+func splitLegacyTag(tag string) (nodeID, protocolType string, ok bool) {
+ for _, candidate := range []string{"vless-reality", "vless", "vmess", "shadowsocks", "hysteria2", "socks", "socks5"} {
+ suffix := "-" + candidate
+ if strings.HasSuffix(tag, suffix) && len(tag) > len(suffix) {
+ return strings.TrimSuffix(tag, suffix), candidate, true
+ }
+ }
+ return "", "", false
+}
diff --git a/internal/state/state.go b/internal/state/state.go
new file mode 100644
index 0000000..97d2d47
--- /dev/null
+++ b/internal/state/state.go
@@ -0,0 +1,174 @@
+package state
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+)
+
+// AppState holds persistent client state.
+type AppState struct {
+ SelectedServer string `json:"selected_server"`
+ SelectedMode string `json:"selected_mode"`
+ LastSync time.Time `json:"last_sync"`
+ AutoConnect bool `json:"auto_connect"`
+ LocalProxyPort int `json:"local_proxy_port,omitempty"`
+ EnabledRuleSets map[string]bool `json:"enabled_rule_sets,omitempty"`
+ CustomBypass []string `json:"custom_bypass_processes,omitempty"`
+ RecommendedServer string `json:"recommended_server,omitempty"`
+ RecommendedNodeID string `json:"recommended_node_id,omitempty"`
+ LastRecommendation time.Time `json:"last_recommendation,omitempty"`
+ RecommendationFetched bool `json:"recommendation_fetched,omitempty"` // true if recommendation was ever fetched
+}
+
+// Store manages persistent state on disk.
+type Store struct {
+ mu sync.Mutex
+ path string
+ dataDir string
+ data AppState
+}
+
+// NewStore creates a state store at the given path.
+func NewStore(dataDir string) *Store {
+ return &Store{
+ path: filepath.Join(dataDir, "state.json"),
+ dataDir: dataDir,
+ data: AppState{
+ SelectedMode: "Комбо (приложения + Re-filter)",
+ AutoConnect: false,
+ },
+ }
+}
+
+// DataDir returns the data directory path.
+func (s *Store) DataDir() string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.dataDir
+}
+
+// Load reads state from disk. Returns default state if file doesn't exist.
+func (s *Store) Load() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ data, err := os.ReadFile(s.path)
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil
+ }
+ return err
+ }
+ return json.Unmarshal(data, &s.data)
+}
+
+// Save writes state to disk.
+func (s *Store) Save() error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+
+ if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(s.data, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(s.path, data, 0o644)
+}
+
+// Get returns a copy of the current state.
+func (s *Store) Get() AppState {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.data
+}
+
+// SetServer updates the selected server.
+func (s *Store) SetServer(tag string) {
+ s.mu.Lock()
+ s.data.SelectedServer = tag
+ s.mu.Unlock()
+}
+
+// SetMode updates the selected routing mode.
+func (s *Store) SetMode(mode string) {
+ s.mu.Lock()
+ s.data.SelectedMode = mode
+ s.mu.Unlock()
+}
+
+// SetLastSync records the last sync time.
+func (s *Store) SetLastSync(t time.Time) {
+ s.mu.Lock()
+ s.data.LastSync = t
+ s.mu.Unlock()
+}
+
+// SetAutoConnect updates the auto-connect setting.
+func (s *Store) SetAutoConnect(v bool) {
+ s.mu.Lock()
+ s.data.AutoConnect = v
+ s.mu.Unlock()
+}
+
+func (s *Store) SetLocalProxyPort(port int) {
+ s.mu.Lock()
+ s.data.LocalProxyPort = port
+ s.mu.Unlock()
+}
+
+// SetRuleSetEnabled enables/disables an optional rule-set.
+func (s *Store) SetRuleSetEnabled(tag string, enabled bool) {
+ s.mu.Lock()
+ if s.data.EnabledRuleSets == nil {
+ s.data.EnabledRuleSets = make(map[string]bool)
+ }
+ s.data.EnabledRuleSets[tag] = enabled
+ s.mu.Unlock()
+}
+
+// IsRuleSetEnabled checks if a rule-set is enabled.
+func (s *Store) IsRuleSetEnabled(tag string) bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if s.data.EnabledRuleSets == nil {
+ return false
+ }
+ return s.data.EnabledRuleSets[tag]
+}
+
+// SetCustomBypass sets custom bypass processes.
+func (s *Store) SetCustomBypass(processes []string) {
+ s.mu.Lock()
+ s.data.CustomBypass = processes
+ s.mu.Unlock()
+}
+
+// GetCustomBypass returns custom bypass processes.
+func (s *Store) GetCustomBypass() []string {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return append([]string{}, s.data.CustomBypass...)
+}
+
+// SetRecommendation saves the server recommendation.
+func (s *Store) SetRecommendation(serverIP, nodeID string) {
+ s.mu.Lock()
+ s.data.RecommendedServer = serverIP
+ s.data.RecommendedNodeID = nodeID
+ s.data.LastRecommendation = time.Now()
+ s.data.RecommendationFetched = true
+ s.mu.Unlock()
+}
+
+// GetRecommendation returns the current recommendation.
+func (s *Store) GetRecommendation() (serverIP, nodeID string, at time.Time) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.data.RecommendedServer, s.data.RecommendedNodeID, s.data.LastRecommendation
+}
diff --git a/internal/sync/fetcher.go b/internal/sync/fetcher.go
new file mode 100644
index 0000000..3ea6df4
--- /dev/null
+++ b/internal/sync/fetcher.go
@@ -0,0 +1,643 @@
+package sync
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ "vpnem/internal/config"
+ "vpnem/internal/models"
+)
+
+// Fetcher pulls configuration from the vpnem server API.
+type Fetcher struct {
+ baseURL string
+ client *http.Client
+}
+
+// NewFetcher creates a new Fetcher.
+func NewFetcher(baseURL string) *Fetcher {
+ return &Fetcher{
+ baseURL: baseURL,
+ client: &http.Client{
+ Timeout: 15 * time.Second,
+ },
+ }
+}
+
+// FetchServers retrieves the server list from the API.
+func (f *Fetcher) FetchServers() (*models.ServersResponse, error) {
+ catalog, err := f.FetchCatalog()
+ if err == nil {
+ return &models.ServersResponse{Servers: CatalogToServers(catalog)}, nil
+ }
+ return nil, fmt.Errorf("fetch catalog: %w", err)
+}
+
+func (f *Fetcher) FetchCatalogV2() (*models.CatalogV2, error) {
+ var resp models.CatalogV2
+ if err := f.getJSON("/api/v2/catalog", &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+func (f *Fetcher) FetchCatalog() (*models.CatalogV2, error) {
+ catalog, err := f.FetchCatalogV2()
+ if err == nil {
+ return catalog, nil
+ }
+ var statusErr *HTTPStatusError
+ if !errors.As(err, &statusErr) || statusErr.StatusCode != http.StatusNotFound {
+ return nil, fmt.Errorf("fetch catalog v2: %w", err)
+ }
+
+ var resp models.ServersResponse
+ if err := f.getJSON("/api/v1/servers", &resp); err != nil {
+ return nil, fmt.Errorf("fetch servers: %w", err)
+ }
+ return ServersToCatalog(resp.Servers), nil
+}
+
+func (f *Fetcher) FetchRoutingPolicy() (*models.RoutingPolicy, error) {
+ var resp models.RoutingPolicy
+ if err := f.getJSON("/api/v1/routing-policy", &resp); err != nil {
+ var statusErr *HTTPStatusError
+ if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusNotFound {
+ return config.DefaultRoutingPolicy(), nil
+ }
+ return nil, fmt.Errorf("fetch routing policy: %w", err)
+ }
+ return config.EffectiveRoutingPolicy(&resp), nil
+}
+
+// FetchRuleSets retrieves the rule-set manifest from the API.
+func (f *Fetcher) FetchRuleSets() (*models.RuleSetManifest, error) {
+ var resp models.RuleSetManifest
+ if err := f.getJSON("/api/v1/ruleset/manifest", &resp); err != nil {
+ return nil, fmt.Errorf("fetch rulesets: %w", err)
+ }
+ return &resp, nil
+}
+
+// DownloadRuleSets downloads all non-optional .srs files to dataDir/rules/.
+// Returns the updated RuleSet list with LocalPath populated.
+func (f *Fetcher) DownloadRuleSets(ruleSets []models.RuleSet, dataDir string) ([]models.RuleSet, error) {
+ rulesDir := filepath.Join(dataDir, "rules")
+ if err := os.MkdirAll(rulesDir, 0755); err != nil {
+ return nil, fmt.Errorf("create rules dir: %w", err)
+ }
+
+ var downloaded []models.RuleSet
+ for _, rs := range ruleSets {
+ if rs.URL == "" {
+ if rs.Optional {
+ continue
+ }
+ return nil, fmt.Errorf("rule-set %s has no URL", rs.Tag)
+ }
+ localPath := filepath.Join(rulesDir, rs.Tag+".srs")
+ resp, err := f.client.Get(rs.URL)
+ if err != nil {
+ if rs.Optional {
+ continue
+ }
+ return nil, fmt.Errorf("download %s: %w", rs.URL, err)
+ }
+ body, err := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ if err != nil {
+ if rs.Optional {
+ continue
+ }
+ return nil, fmt.Errorf("read %s: %w", rs.URL, err)
+ }
+ if resp.StatusCode != http.StatusOK {
+ if rs.Optional {
+ continue
+ }
+ return nil, fmt.Errorf("download %s: HTTP %d", rs.URL, resp.StatusCode)
+ }
+ if err := os.WriteFile(localPath, body, 0644); err != nil {
+ return nil, fmt.Errorf("write %s: %w", localPath, err)
+ }
+ rs.LocalPath = localPath
+ downloaded = append(downloaded, rs)
+ }
+ return downloaded, nil
+}
+
+// FetchVersion retrieves the latest client version info.
+func (f *Fetcher) FetchVersion() (*models.VersionResponse, error) {
+ var resp models.VersionResponse
+ if err := f.getJSON("/api/v1/version", &resp); err != nil {
+ return nil, fmt.Errorf("fetch version: %w", err)
+ }
+ return &resp, nil
+}
+
+// ServerIPs extracts all unique server IPs from the server list.
+func ServerIPs(servers []models.Server) []string {
+ seen := make(map[string]bool)
+ var ips []string
+ for _, s := range servers {
+ if !seen[s.Server] {
+ seen[s.Server] = true
+ ips = append(ips, s.Server)
+ }
+ }
+ return ips
+}
+
+// ReportError sends error logs to the server (best-effort, non-blocking).
+func (f *Fetcher) ReportError(version, osName string, lines []string) {
+ payload := map[string]any{
+ "version": version,
+ "os": osName,
+ "lines": lines,
+ }
+ data, err := json.Marshal(payload)
+ if err != nil {
+ return
+ }
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, f.baseURL+"/logs2026vpnem/errors", bytes.NewReader(data))
+ if err != nil {
+ return
+ }
+ req.Header.Set("Content-Type", "application/json")
+ resp, err := f.client.Do(req)
+ if err != nil {
+ return
+ }
+ defer resp.Body.Close()
+ io.Copy(io.Discard, resp.Body)
+}
+
+func (f *Fetcher) getJSON(path string, v any) error {
+ resp, err := f.client.Get(f.baseURL + path)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)}
+ }
+
+ return json.NewDecoder(resp.Body).Decode(v)
+}
+
+type HTTPStatusError struct {
+ StatusCode int
+ Body string
+}
+
+func (e *HTTPStatusError) Error() string {
+ return fmt.Sprintf("HTTP %d: %s", e.StatusCode, e.Body)
+}
+
+// ReportConnect sends a connection report to the server.
+// Server auto-detects client real IP from X-Forwarded-For.
+func (f *Fetcher) ReportConnect(serverIP, nodeID string) (*models.RecommendationResponse, error) {
+ req := models.ConnectRequest{
+ ServerIP: serverIP,
+ NodeID: nodeID,
+ OS: func() string {
+ if runtime.GOOS == "windows" {
+ return "windows"
+ }
+ return "linux"
+ }(),
+ Version: "", // server doesn't need version for rebalancing
+ }
+
+ payload, err := json.Marshal(req)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := f.client.Post(f.baseURL+"/api/v1/connect", "application/json", bytes.NewReader(payload))
+ if err != nil {
+ return nil, fmt.Errorf("report connect: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)}
+ }
+
+ var recommendation models.RecommendationResponse
+ if err := json.NewDecoder(resp.Body).Decode(&recommendation); err != nil {
+ return nil, fmt.Errorf("decode recommendation: %w", err)
+ }
+ return &recommendation, nil
+}
+
+// ReportDisconnect notifies the server that a client disconnected.
+func (f *Fetcher) ReportDisconnect(serverIP, nodeID string) error {
+ req := models.DisconnectRequest{
+ ServerIP: serverIP,
+ NodeID: nodeID,
+ }
+
+ payload, err := json.Marshal(req)
+ if err != nil {
+ return err
+ }
+
+ resp, err := f.client.Post(f.baseURL+"/api/v1/disconnect", "application/json", bytes.NewReader(payload))
+ if err != nil {
+ return fmt.Errorf("report disconnect: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)}
+ }
+ return nil
+}
+
+// GetRecommendation fetches a recommendation for the client.
+// Server auto-detects client real IP from X-Forwarded-For.
+func (f *Fetcher) GetRecommendation() (*models.RecommendationResponse, error) {
+ url := f.baseURL + "/api/v1/recommend"
+ resp, err := f.client.Get(url)
+ if err != nil {
+ return nil, fmt.Errorf("get recommendation: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, &HTTPStatusError{StatusCode: resp.StatusCode, Body: string(body)}
+ }
+
+ var recommendation models.RecommendationResponse
+ if err := json.NewDecoder(resp.Body).Decode(&recommendation); err != nil {
+ return nil, fmt.Errorf("decode recommendation: %w", err)
+ }
+ return &recommendation, nil
+}
+
+func CatalogToServers(catalog *models.CatalogV2) []models.Server {
+ if catalog == nil {
+ return nil
+ }
+ servers := make([]models.Server, 0)
+ for _, node := range catalog.Nodes {
+ if multi, ok := nodeToSplitServer(node); ok {
+ servers = append(servers, multi)
+ }
+ host := node.PublicHost
+ if host == "" {
+ if node.Domain != "" {
+ host = node.Domain
+ } else {
+ host = node.Host
+ }
+ }
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ if isSplitProtocol(protocol.Type) && hasSplitPair(node) {
+ continue
+ }
+ server := models.Server{
+ Tag: legacyTag(node, protocol),
+ Region: node.Region,
+ Type: protocol.Type,
+ Server: host,
+ ServerPort: protocol.Port,
+ }
+ if protocol.TLS != nil {
+ server.TLS = &models.TLS{
+ Enabled: protocol.TLS.Enabled,
+ ServerName: protocol.TLS.ServerName,
+ Insecure: protocol.TLS.Insecure,
+ ALPN: protocol.TLS.ALPN,
+ MinVersion: protocol.TLS.MinVersion,
+ MaxVersion: protocol.TLS.MaxVersion,
+ }
+ if protocol.TLS.Reality != nil && protocol.TLS.Reality.Enabled {
+ server.TLS.Reality = &models.Reality{
+ Enabled: true,
+ PublicKey: protocol.TLS.Reality.PublicKey,
+ ShortID: protocol.TLS.Reality.ShortID,
+ Fingerprint: protocol.TLS.Reality.Fingerprint,
+ }
+ }
+ }
+ switch protocol.Type {
+ case "vless", "vless-reality", "vmess":
+ if protocol.Auth != nil {
+ server.UUID = protocol.Auth.UUID
+ }
+ if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" {
+ server.Transport = &models.Transport{
+ Type: transportType,
+ Path: extraString(protocol.Extra, "path"),
+ }
+ } else if path := extraString(protocol.Extra, "path"); path != "" {
+ server.Transport = &models.Transport{
+ Type: "ws",
+ Path: path,
+ }
+ }
+ case "shadowsocks":
+ if protocol.Auth != nil {
+ server.Method = protocol.Auth.Method
+ server.Password = protocol.Auth.Password
+ }
+ case "hysteria2":
+ if protocol.Auth != nil {
+ server.Password = protocol.Auth.Password
+ }
+ server.ObfsPassword = extraString(protocol.Extra, "obfs_password")
+ server.UpMbps = extraInt(protocol.Extra, "up_mbps", 0)
+ server.DownMbps = extraInt(protocol.Extra, "down_mbps", 0)
+ if server.TLS == nil {
+ server.TLS = &models.TLS{}
+ }
+ server.TLS.Enabled = true
+ server.TLS.Insecure = true
+ if len(server.TLS.ALPN) == 0 {
+ server.TLS.ALPN = []string{"h3"}
+ }
+ if server.TLS.MinVersion == "" {
+ server.TLS.MinVersion = "1.3"
+ }
+ if server.TLS.MaxVersion == "" {
+ server.TLS.MaxVersion = "1.3"
+ }
+ case "socks5":
+ server.Type = "socks"
+ }
+ servers = append(servers, server)
+ }
+ }
+ return servers
+}
+
+func nodeToSplitServer(node models.CatalogNode) (models.Server, bool) {
+ if !hasSplitPair(node) {
+ return models.Server{}, false
+ }
+ host := node.PublicHost
+ if host == "" {
+ if node.Domain != "" {
+ host = node.Domain
+ } else {
+ host = node.Host
+ }
+ }
+ var main models.Server
+ var hy2 models.Server
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ switch protocol.Type {
+ case "vless-reality":
+ main = catalogProtocolToServer(node, protocol, host)
+ case "hysteria2":
+ hy2 = catalogProtocolToServer(node, protocol, host)
+ }
+ }
+ if main.Type == "" || hy2.Type == "" {
+ return models.Server{}, false
+ }
+ main.Tag = node.ID + "-multi"
+ main.Companions = []models.Server{hy2}
+ return main, true
+}
+
+func hasSplitPair(node models.CatalogNode) bool {
+ hasReality := false
+ hasHy2 := false
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ switch protocol.Type {
+ case "vless-reality":
+ hasReality = true
+ case "hysteria2":
+ hasHy2 = true
+ }
+ }
+ return hasReality && hasHy2
+}
+
+func isSplitProtocol(protocolType string) bool {
+ return protocolType == "vless-reality" || protocolType == "hysteria2"
+}
+
+func catalogProtocolToServer(node models.CatalogNode, protocol models.CatalogProtocol, host string) models.Server {
+ server := models.Server{
+ Tag: legacyTag(node, protocol),
+ Region: node.Region,
+ Type: protocol.Type,
+ Server: host,
+ ServerPort: protocol.Port,
+ }
+ if protocol.TLS != nil {
+ server.TLS = &models.TLS{
+ Enabled: protocol.TLS.Enabled,
+ ServerName: protocol.TLS.ServerName,
+ Insecure: protocol.TLS.Insecure,
+ ALPN: protocol.TLS.ALPN,
+ MinVersion: protocol.TLS.MinVersion,
+ MaxVersion: protocol.TLS.MaxVersion,
+ }
+ if protocol.TLS.Reality != nil && protocol.TLS.Reality.Enabled {
+ server.TLS.Reality = &models.Reality{
+ Enabled: true,
+ PublicKey: protocol.TLS.Reality.PublicKey,
+ ShortID: protocol.TLS.Reality.ShortID,
+ Fingerprint: protocol.TLS.Reality.Fingerprint,
+ }
+ }
+ }
+ switch protocol.Type {
+ case "vless", "vless-reality", "vmess":
+ if protocol.Auth != nil {
+ server.UUID = protocol.Auth.UUID
+ }
+ if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" {
+ server.Transport = &models.Transport{
+ Type: transportType,
+ Path: extraString(protocol.Extra, "path"),
+ }
+ } else if path := extraString(protocol.Extra, "path"); path != "" {
+ server.Transport = &models.Transport{
+ Type: "ws",
+ Path: path,
+ }
+ }
+ case "shadowsocks":
+ if protocol.Auth != nil {
+ server.Method = protocol.Auth.Method
+ server.Password = protocol.Auth.Password
+ }
+ case "hysteria2":
+ if protocol.Auth != nil {
+ server.Password = protocol.Auth.Password
+ }
+ server.ObfsPassword = extraString(protocol.Extra, "obfs_password")
+ server.UpMbps = extraInt(protocol.Extra, "up_mbps", 0)
+ server.DownMbps = extraInt(protocol.Extra, "down_mbps", 0)
+ if server.TLS == nil {
+ server.TLS = &models.TLS{}
+ }
+ server.TLS.Enabled = true
+ server.TLS.Insecure = true
+ if len(server.TLS.ALPN) == 0 {
+ server.TLS.ALPN = []string{"h3"}
+ }
+ if server.TLS.MinVersion == "" {
+ server.TLS.MinVersion = "1.3"
+ }
+ if server.TLS.MaxVersion == "" {
+ server.TLS.MaxVersion = "1.3"
+ }
+ case "socks5":
+ server.Type = "socks"
+ }
+ return server
+}
+
+func ServersToCatalog(servers []models.Server) *models.CatalogV2 {
+ nodesByID := make(map[string]*models.CatalogNode, len(servers))
+ order := make([]string, 0, len(servers))
+ for _, server := range servers {
+ nodeID := server.Tag
+ if existingID, protocolType, ok := splitLegacyTag(server.Tag); ok && existingID != "" {
+ nodeID = existingID
+ server.Type = protocolType
+ }
+
+ node := nodesByID[nodeID]
+ if node == nil {
+ node = &models.CatalogNode{
+ ID: nodeID,
+ Name: nodeID,
+ Region: server.Region,
+ Host: server.Server,
+ PublicHost: server.Server,
+ Status: "published",
+ }
+ nodesByID[nodeID] = node
+ order = append(order, nodeID)
+ }
+ node.Protocols = append(node.Protocols, serverToCatalogProtocol(server))
+ }
+
+ nodes := make([]models.CatalogNode, 0, len(order))
+ for _, id := range order {
+ nodes = append(nodes, *nodesByID[id])
+ }
+ return &models.CatalogV2{
+ Version: "legacy-adapter",
+ Nodes: nodes,
+ }
+}
+
+func serverToCatalogProtocol(server models.Server) models.CatalogProtocol {
+ protocolType := server.Type
+ if protocolType == "socks" {
+ protocolType = "socks5"
+ }
+ protocol := models.CatalogProtocol{
+ Type: protocolType,
+ Enabled: true,
+ Port: server.ServerPort,
+ TLS: server.TLS,
+ Extra: make(map[string]any),
+ }
+ switch server.Type {
+ case "vless", "vless-reality", "vmess":
+ protocol.Auth = &models.CatalogAuth{UUID: server.UUID}
+ protocol.Extra["legacy_tag"] = server.Tag
+ if server.Transport != nil {
+ protocol.Extra["transport_type"] = server.Transport.Type
+ if server.Transport.Path != "" {
+ protocol.Extra["path"] = server.Transport.Path
+ }
+ }
+ case "shadowsocks":
+ protocol.Auth = &models.CatalogAuth{Method: server.Method, Password: server.Password}
+ protocol.Extra["legacy_tag"] = server.Tag
+ case "hysteria2":
+ protocol.Auth = &models.CatalogAuth{Password: server.Password}
+ protocol.Extra["legacy_tag"] = server.Tag
+ if server.ObfsPassword != "" {
+ protocol.Extra["obfs_password"] = server.ObfsPassword
+ }
+ if server.UpMbps > 0 {
+ protocol.Extra["up_mbps"] = server.UpMbps
+ }
+ if server.DownMbps > 0 {
+ protocol.Extra["down_mbps"] = server.DownMbps
+ }
+ case "socks":
+ protocol.Extra["legacy_tag"] = server.Tag
+ protocol.Extra["udp_over_tcp"] = server.UDPOverTCP
+ }
+ if len(protocol.Extra) == 0 {
+ protocol.Extra = nil
+ }
+ return protocol
+}
+
+func splitLegacyTag(tag string) (nodeID, protocolType string, ok bool) {
+ for _, candidate := range []string{"vless-reality", "vless", "vmess", "shadowsocks", "hysteria2", "socks", "socks5"} {
+ suffix := "-" + candidate
+ if len(tag) > len(suffix) && tag[len(tag)-len(suffix):] == suffix {
+ return tag[:len(tag)-len(suffix)], candidate, true
+ }
+ }
+ return "", "", false
+}
+
+func legacyTag(node models.CatalogNode, protocol models.CatalogProtocol) string {
+ if tag := extraString(protocol.Extra, "legacy_tag"); tag != "" {
+ return tag
+ }
+ return node.ID + "-" + protocol.Type
+}
+
+func extraString(extra map[string]any, key string) string {
+ if extra == nil {
+ return ""
+ }
+ value, _ := extra[key].(string)
+ return value
+}
+
+func extraInt(extra map[string]any, key string, fallback int) int {
+ if extra == nil {
+ return fallback
+ }
+ switch value := extra[key].(type) {
+ case int:
+ return value
+ case float64:
+ return int(value)
+ default:
+ return fallback
+ }
+}
diff --git a/internal/sync/fetcher_test.go b/internal/sync/fetcher_test.go
new file mode 100644
index 0000000..cdf3e73
--- /dev/null
+++ b/internal/sync/fetcher_test.go
@@ -0,0 +1,300 @@
+package sync
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "vpnem/internal/models"
+)
+
+func TestCatalogToServers(t *testing.T) {
+ catalog := &models.CatalogV2{
+ Version: "2",
+ Nodes: []models.CatalogNode{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ PublicHost: "nl-01.example.com",
+ Protocols: []models.CatalogProtocol{
+ {
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"},
+ Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"},
+ Extra: map[string]any{"transport_type": "ws", "path": "/ws"},
+ },
+ {
+ Type: "vmess",
+ Enabled: true,
+ Port: 8444,
+ TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"},
+ Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"},
+ Extra: map[string]any{"path": "/vmess"},
+ },
+ {
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 9443,
+ TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"},
+ Auth: &models.CatalogAuth{Password: "hy2-secret"},
+ Extra: map[string]any{"obfs_password": "obfs-secret", "up_mbps": 80, "down_mbps": 90},
+ },
+ },
+ },
+ },
+ }
+
+ servers := CatalogToServers(catalog)
+ if len(servers) != 3 {
+ t.Fatalf("len(servers) = %d, want 3", len(servers))
+ }
+ if servers[1].Type != "vmess" {
+ t.Fatalf("expected vmess, got %q", servers[1].Type)
+ }
+ if servers[2].Type != "hysteria2" {
+ t.Fatalf("expected hysteria2, got %q", servers[2].Type)
+ }
+ if servers[2].ObfsPassword != "obfs-secret" {
+ t.Fatalf("unexpected hysteria2 obfs password")
+ }
+}
+
+func TestFetchServersPrefersCatalogV2(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v2/catalog":
+ _ = json.NewEncoder(w).Encode(models.CatalogV2{
+ Version: "2",
+ Nodes: []models.CatalogNode{
+ {
+ ID: "nl-01",
+ Name: "NL 01",
+ Region: "nl",
+ Host: "203.0.113.10",
+ PublicHost: "nl-01.example.com",
+ Protocols: []models.CatalogProtocol{
+ {Type: "vmess", Enabled: true, Port: 8444, Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"}},
+ },
+ },
+ },
+ })
+ case "/api/v1/servers":
+ t.Fatal("legacy servers endpoint should not be used when catalog-v2 is available")
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ fetcher := NewFetcher(server.URL)
+ resp, err := fetcher.FetchServers()
+ if err != nil {
+ t.Fatalf("FetchServers error = %v", err)
+ }
+ if len(resp.Servers) != 1 {
+ t.Fatalf("expected 1 server, got %d", len(resp.Servers))
+ }
+ if resp.Servers[0].Type != "vmess" {
+ t.Fatalf("expected vmess, got %q", resp.Servers[0].Type)
+ }
+}
+
+func TestFetchServersFallsBackToLegacy(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v2/catalog":
+ http.NotFound(w, r)
+ case "/api/v1/servers":
+ _ = json.NewEncoder(w).Encode(models.ServersResponse{
+ Servers: []models.Server{{Tag: "legacy", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}},
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ fetcher := NewFetcher(server.URL)
+ resp, err := fetcher.FetchServers()
+ if err != nil {
+ t.Fatalf("FetchServers error = %v", err)
+ }
+ if len(resp.Servers) != 1 || !strings.EqualFold(resp.Servers[0].Tag, "legacy") {
+ t.Fatalf("unexpected legacy response: %+v", resp.Servers)
+ }
+}
+
+func TestFetchCatalogFallsBackToLegacy(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v2/catalog":
+ http.NotFound(w, r)
+ case "/api/v1/servers":
+ _ = json.NewEncoder(w).Encode(models.ServersResponse{
+ Servers: []models.Server{
+ {Tag: "legacy-vless", Region: "nl", Type: "vless", Server: "legacy.example.com", ServerPort: 443, UUID: "11111111-1111-1111-1111-111111111111"},
+ },
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ fetcher := NewFetcher(server.URL)
+ catalog, err := fetcher.FetchCatalog()
+ if err != nil {
+ t.Fatalf("FetchCatalog error = %v", err)
+ }
+ if catalog.Version != "legacy-adapter" {
+ t.Fatalf("expected legacy-adapter version, got %q", catalog.Version)
+ }
+ if len(catalog.Nodes) != 1 || len(catalog.Nodes[0].Protocols) != 1 {
+ t.Fatalf("unexpected catalog shape: %+v", catalog)
+ }
+ if catalog.Nodes[0].Protocols[0].Type != "vless" {
+ t.Fatalf("expected vless protocol, got %q", catalog.Nodes[0].Protocols[0].Type)
+ }
+}
+
+func TestFetchRoutingPolicyFallsBackToDefault(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ http.NotFound(w, r)
+ }))
+ defer server.Close()
+
+ fetcher := NewFetcher(server.URL)
+ policy, err := fetcher.FetchRoutingPolicy()
+ if err != nil {
+ t.Fatalf("FetchRoutingPolicy error = %v", err)
+ }
+ if policy.Version == "" {
+ t.Fatalf("expected default policy version")
+ }
+ if len(policy.AlwaysDirectProcesses) == 0 {
+ t.Fatalf("expected default direct processes")
+ }
+}
+
+func TestFetchRoutingPolicy(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api/v1/routing-policy":
+ _ = json.NewEncoder(w).Encode(models.RoutingPolicy{
+ Version: "remote-policy",
+ AlwaysDirectProcesses: []string{"chromium.exe"},
+ BlockedDomains: []string{"example.com"},
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer server.Close()
+
+ fetcher := NewFetcher(server.URL)
+ policy, err := fetcher.FetchRoutingPolicy()
+ if err != nil {
+ t.Fatalf("FetchRoutingPolicy error = %v", err)
+ }
+ if policy.Version != "remote-policy" {
+ t.Fatalf("expected remote-policy, got %q", policy.Version)
+ }
+ if len(policy.AlwaysDirectProcesses) != 1 || policy.AlwaysDirectProcesses[0] != "chromium.exe" {
+ t.Fatalf("unexpected routing policy: %+v", policy)
+ }
+}
+
+func TestServersToCatalog(t *testing.T) {
+ catalog := ServersToCatalog([]models.Server{
+ {
+ Tag: "nl-01-vless",
+ Region: "nl",
+ Type: "vless",
+ Server: "nl-01.example.com",
+ ServerPort: 443,
+ UUID: "11111111-1111-1111-1111-111111111111",
+ TLS: &models.TLS{Enabled: true, ServerName: "nl-01.example.com"},
+ Transport: &models.Transport{Type: "ws", Path: "/ws"},
+ },
+ {
+ Tag: "nl-01-hysteria2",
+ Region: "nl",
+ Type: "hysteria2",
+ Server: "nl-01.example.com",
+ ServerPort: 9443,
+ Password: "hy2-secret",
+ ObfsPassword: "obfs-secret",
+ },
+ })
+
+ if catalog.Version != "legacy-adapter" {
+ t.Fatalf("unexpected version %q", catalog.Version)
+ }
+ if len(catalog.Nodes) != 1 {
+ t.Fatalf("expected one node, got %d", len(catalog.Nodes))
+ }
+ if len(catalog.Nodes[0].Protocols) != 2 {
+ t.Fatalf("expected two protocols, got %d", len(catalog.Nodes[0].Protocols))
+ }
+ if catalog.Nodes[0].Protocols[1].Extra["obfs_password"] != "obfs-secret" {
+ t.Fatalf("expected obfs password in extra")
+ }
+}
+
+func TestCatalogToServersMultiProtocolNode(t *testing.T) {
+ catalog := &models.CatalogV2{
+ Version: "2",
+ Nodes: []models.CatalogNode{
+ {
+ ID: "nl-multi-01",
+ Name: "NL Multi",
+ Region: "nl",
+ Host: "203.0.113.55",
+ PublicHost: "203.0.113.55",
+ Protocols: []models.CatalogProtocol{
+ {
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"},
+ TLS: &models.TLS{
+ Enabled: true,
+ ServerName: "www.microsoft.com",
+ Reality: &models.Reality{
+ Enabled: true,
+ PublicKey: "pubkey",
+ ShortID: "shortid",
+ Fingerprint: "chrome",
+ },
+ },
+ },
+ {
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 443,
+ Auth: &models.CatalogAuth{Password: "hy2-secret"},
+ TLS: &models.TLS{Enabled: true, Insecure: true, ALPN: []string{"h3"}},
+ Extra: map[string]any{"obfs_password": "obfs-secret", "up_mbps": 100, "down_mbps": 100},
+ },
+ },
+ },
+ },
+ }
+
+ servers := CatalogToServers(catalog)
+ if len(servers) != 1 {
+ t.Fatalf("expected 1 synthetic multi server, got %d", len(servers))
+ }
+ if servers[0].Tag != "nl-multi-01-multi" {
+ t.Fatalf("unexpected synthetic tag %q", servers[0].Tag)
+ }
+ if len(servers[0].Companions) != 1 || servers[0].Companions[0].Type != "hysteria2" {
+ t.Fatalf("expected hysteria2 companion, got %+v", servers[0].Companions)
+ }
+}
diff --git a/internal/sync/health.go b/internal/sync/health.go
new file mode 100644
index 0000000..4d6ceca
--- /dev/null
+++ b/internal/sync/health.go
@@ -0,0 +1,33 @@
+package sync
+
+import (
+ "fmt"
+ "net"
+ "time"
+
+ "vpnem/internal/models"
+)
+
+// HealthCheck tests if a server's proxy port is reachable.
+func HealthCheck(server models.Server, timeout time.Duration) error {
+ addr := fmt.Sprintf("%s:%d", server.Server, server.ServerPort)
+ conn, err := net.DialTimeout("tcp", addr, timeout)
+ if err != nil {
+ return fmt.Errorf("server %s unreachable: %w", server.Tag, err)
+ }
+ conn.Close()
+ return nil
+}
+
+// FindHealthyServer returns the first healthy non-RU server from the list.
+func FindHealthyServer(servers []models.Server, timeout time.Duration) *models.Server {
+ for _, s := range servers {
+ if s.Region == "RU" {
+ continue
+ }
+ if err := HealthCheck(s, timeout); err == nil {
+ return &s
+ }
+ }
+ return nil
+}
diff --git a/internal/sync/latency.go b/internal/sync/latency.go
new file mode 100644
index 0000000..dd3268b
--- /dev/null
+++ b/internal/sync/latency.go
@@ -0,0 +1,62 @@
+package sync
+
+import (
+ "fmt"
+ "net"
+ "sort"
+ "sync"
+ "time"
+
+ "vpnem/internal/models"
+)
+
+// LatencyResult holds a server's latency measurement.
+type LatencyResult struct {
+ Tag string `json:"tag"`
+ Region string `json:"region"`
+ Latency int `json:"latency_ms"` // -1 means unreachable
+}
+
+// MeasureLatency pings all servers concurrently and returns results sorted by latency.
+func MeasureLatency(servers []models.Server, timeout time.Duration) []LatencyResult {
+ var wg sync.WaitGroup
+ results := make([]LatencyResult, len(servers))
+
+ for i, s := range servers {
+ wg.Add(1)
+ go func(idx int, srv models.Server) {
+ defer wg.Done()
+ ms := tcpPing(srv.Server, srv.ServerPort, timeout)
+ results[idx] = LatencyResult{
+ Tag: srv.Tag,
+ Region: srv.Region,
+ Latency: ms,
+ }
+ }(i, s)
+ }
+
+ wg.Wait()
+
+ sort.Slice(results, func(i, j int) bool {
+ if results[i].Latency == -1 {
+ return false
+ }
+ if results[j].Latency == -1 {
+ return true
+ }
+ return results[i].Latency < results[j].Latency
+ })
+
+ return results
+}
+
+func tcpPing(host string, port int, timeout time.Duration) int {
+ addr := fmt.Sprintf("%s:%d", host, port)
+ start := time.Now()
+ conn, err := net.DialTimeout("tcp", addr, timeout)
+ if err != nil {
+ return -1
+ }
+ conn.Close()
+ return int(time.Since(start).Milliseconds())
+}
diff --git a/internal/sync/updater.go b/internal/sync/updater.go
new file mode 100644
index 0000000..983ce43
--- /dev/null
+++ b/internal/sync/updater.go
@@ -0,0 +1,180 @@
+package sync
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+)
+
+// Updater checks for and downloads client updates.
+type Updater struct {
+ fetcher *Fetcher
+ currentVer string
+ dataDir string
+}
+
+// NewUpdater creates an updater.
+func NewUpdater(fetcher *Fetcher, currentVersion, dataDir string) *Updater {
+ return &Updater{
+ fetcher: fetcher,
+ currentVer: currentVersion,
+ dataDir: dataDir,
+ }
+}
+
+// UpdateInfo describes an available update.
+type UpdateInfo struct {
+ Available bool `json:"available"`
+ Version string `json:"version"`
+ Changelog string `json:"changelog"`
+ CurrentVer string `json:"current_version"`
+}
+
+// Check returns info about available updates.
+func (u *Updater) Check() (*UpdateInfo, error) {
+ ver, err := u.fetcher.FetchVersion()
+ if err != nil {
+ return nil, fmt.Errorf("check update: %w", err)
+ }
+
+ return &UpdateInfo{
+ Available: ver.Version != u.currentVer,
+ Version: ver.Version,
+ Changelog: ver.Changelog,
+ CurrentVer: u.currentVer,
+ }, nil
+}
+
+// Download fetches the new binary, verifies checksum, and prepares for restart.
+// On success it returns "restart_pending" and the caller should exit gracefully.
+func (u *Updater) Download() (string, error) {
+ ver, err := u.fetcher.FetchVersion()
+ if err != nil {
+ return "", fmt.Errorf("fetch version: %w", err)
+ }
+
+ if ver.URL == "" {
+ suffix := "linux-amd64"
+ if runtime.GOOS == "windows" {
+ suffix = "windows-amd64.exe"
+ }
+ ver.URL = u.fetcher.baseURL + "/releases/vpnem-" + suffix
+ }
+
+ client := &http.Client{Timeout: 5 * time.Minute}
+ resp, err := client.Get(ver.URL)
+ if err != nil {
+ return "", fmt.Errorf("download: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", fmt.Errorf("download: HTTP %d", resp.StatusCode)
+ }
+
+ ext := ""
+ if runtime.GOOS == "windows" {
+ ext = ".exe"
+ }
+
+ // Download to temp file first
+ downloadPath := filepath.Join(u.dataDir, "vpnem-new"+ext)
+ f, err := os.Create(downloadPath)
+ if err != nil {
+ return "", fmt.Errorf("create file: %w", err)
+ }
+
+ // Track SHA256 while downloading
+ hasher := sha256.New()
+ written, err := io.Copy(io.MultiWriter(f, hasher), resp.Body)
+ f.Close()
+ if err != nil {
+ os.Remove(downloadPath)
+ return "", fmt.Errorf("write update: %w", err)
+ }
+
+ // Verify SHA256 checksum if provided
+ if ver.SHA256 != "" {
+ gotHash := hex.EncodeToString(hasher.Sum(nil))
+ if gotHash != ver.SHA256 {
+ os.Remove(downloadPath)
+ return "", fmt.Errorf("checksum mismatch: expected %s, got %s (%.1f MB)", ver.SHA256, gotHash, float64(written)/1024/1024)
+ }
+ log.Printf("update: sha256 verified (%.1f MB)", float64(written)/1024/1024)
+ }
+
+ // Clean stale configs so new version starts fresh
+ os.Remove(filepath.Join(u.dataDir, "state.json"))
+ os.Remove(filepath.Join(u.dataDir, "config.json"))
+ os.Remove(filepath.Join(u.dataDir, "cache.db"))
+
+ currentBin, _ := os.Executable()
+ if currentBin == "" {
+ return "", fmt.Errorf("could not determine current binary")
+ }
+
+ if runtime.GOOS == "windows" {
+ // Windows: can't overwrite running exe
+ // Strategy: rename old to .old, copy new in place
+ // If .old already exists from a previous failed update, remove it
+ oldBin := currentBin + ".old"
+ os.Remove(oldBin)
+
+ if err := os.Rename(currentBin, oldBin); err != nil {
+ return "", fmt.Errorf("rename old binary: %w", err)
+ }
+
+ if err := copyFile(downloadPath, currentBin); err != nil {
+ // Restore old binary
+ os.Remove(currentBin)
+ os.Rename(oldBin, currentBin)
+ os.Remove(downloadPath)
+ return "", fmt.Errorf("copy new binary: %w", err)
+ }
+
+ os.Remove(downloadPath)
+ log.Printf("update: binary replaced, version %s", ver.Version)
+ return "restart_pending", nil
+ }
+
+ // Linux: overwrite in place
+ if err := copyFile(downloadPath, currentBin); err != nil {
+ os.Remove(downloadPath)
+ return "", fmt.Errorf("copy new binary: %w", err)
+ }
+ os.Remove(downloadPath)
+ log.Printf("update: binary replaced, version %s", ver.Version)
+ return "restart_pending", nil
+}
+
+func copyFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+
+ _, err = io.Copy(out, in)
+ if err != nil {
+ return err
+ }
+
+ // Preserve executable permission on Linux
+ if runtime.GOOS != "windows" {
+ os.Chmod(dst, 0o755)
+ }
+ return nil
+}