summaryrefslogtreecommitdiff
path: root/internal/api
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api')
-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
8 files changed, 6002 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
+}