diff options
Diffstat (limited to 'internal/api/control.go')
| -rw-r--r-- | internal/api/control.go | 3781 |
1 files changed, 3781 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 © +} + +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) +} |
