summaryrefslogtreecommitdiff
path: root/internal/api/control.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/control.go')
-rw-r--r--internal/api/control.go3781
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 &copy
+}
+
+func buildQuickProvisionNode(req quickProvisionRequest) (control.Node, string, error) {
+ host := strings.TrimSpace(req.Host)
+ password := strings.TrimSpace(req.RootPassword)
+ if host == "" {
+ return control.Node{}, "", errors.New("host is required")
+ }
+ if password == "" {
+ return control.Node{}, "", errors.New("root_password is required")
+ }
+
+ region := strings.TrimSpace(req.Region)
+ if region == "" {
+ region = "auto"
+ }
+ provider := strings.TrimSpace(req.Provider)
+ if provider == "" {
+ provider = "custom-vps"
+ }
+ acmeEmail := strings.TrimSpace(req.ACMEEmail)
+ if acmeEmail == "" {
+ acmeEmail = "admin@em-sysadmin.xyz"
+ }
+ enableMulti := req.EnableMulti || (req.EnableReality && req.EnableHY2)
+ nodeKind := "server"
+ if req.EnableSocks && !enableMulti && !req.EnableSS && !req.EnableVMess && !req.EnableVLESS && !req.EnableReality && !req.EnableHY2 {
+ nodeKind = "socks5"
+ } else if enableMulti {
+ nodeKind = "multi"
+ }
+
+ nodeID := "node-" + strings.ReplaceAll(host, ".", "-")
+ if suffix, err := controlRandomHex(2); err == nil {
+ nodeID += "-" + suffix
+ }
+ uuid, err := controlRandomUUID()
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ vmessUUID, err := controlRandomUUID()
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ ssPassword, err := controlRandomHex(16)
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ hy2Password, err := controlRandomBase64(16)
+ if err != nil {
+ return control.Node{}, "", err
+ }
+ hy2ObfsPassword, err := controlRandomHex(32)
+ if err != nil {
+ return control.Node{}, "", err
+ }
+
+ protocols := make([]control.ProtocolProfile, 0, 3)
+ if req.EnableVLESS {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vless",
+ Enabled: true,
+ Port: 443,
+ TLS: &control.TLSProfile{
+ Enabled: true,
+ },
+ Auth: &control.AuthProfile{
+ UUID: uuid,
+ },
+ Extra: map[string]any{
+ "transport_type": "ws",
+ "path": "/ws",
+ },
+ })
+ }
+ if req.EnableReality && !enableMulti {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ UUID: uuid,
+ },
+ Reality: &control.VLESSRealityProfile{
+ ServerName: "www.nokia.com",
+ ServerPort: 443,
+ Fingerprint: "chrome",
+ },
+ })
+ }
+ if req.EnableSocks {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "socks5",
+ Enabled: true,
+ Port: 54101,
+ })
+ }
+ if req.EnableSS {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "shadowsocks",
+ Enabled: true,
+ Port: 8443,
+ Auth: &control.AuthProfile{
+ Method: "2022-blake3-aes-128-gcm",
+ Password: ssPassword,
+ },
+ })
+ }
+ if req.EnableVMess {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vmess",
+ Enabled: true,
+ Port: 8444,
+ TLS: &control.TLSProfile{
+ Enabled: true,
+ },
+ Auth: &control.AuthProfile{
+ UUID: vmessUUID,
+ },
+ Extra: map[string]any{
+ "path": "/vmess",
+ },
+ })
+ }
+ if req.EnableHY2 && !enableMulti {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ Password: hy2Password,
+ },
+ Hysteria2: &control.Hysteria2Profile{
+ Port: 443,
+ UpMbps: 100,
+ DownMbps: 100,
+ ObfsPassword: hy2ObfsPassword,
+ UserPassword: hy2Password,
+ CertPath: "/etc/sing-box/cert.pem",
+ KeyPath: "/etc/sing-box/key.pem",
+ },
+ })
+ }
+ if enableMulti {
+ protocols = append(protocols, control.ProtocolProfile{
+ Type: "vless-reality",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ UUID: uuid,
+ },
+ Reality: &control.VLESSRealityProfile{
+ ServerName: "www.nokia.com",
+ ServerPort: 443,
+ Fingerprint: "chrome",
+ },
+ }, control.ProtocolProfile{
+ Type: "hysteria2",
+ Enabled: true,
+ Port: 443,
+ Auth: &control.AuthProfile{
+ Password: hy2Password,
+ },
+ Hysteria2: &control.Hysteria2Profile{
+ Port: 443,
+ UpMbps: 100,
+ DownMbps: 100,
+ ObfsPassword: hy2ObfsPassword,
+ UserPassword: hy2Password,
+ CertPath: "/etc/sing-box/cert.pem",
+ KeyPath: "/etc/sing-box/key.pem",
+ },
+ })
+ }
+ if len(protocols) == 0 {
+ return control.Node{}, "", errors.New("at least one protocol must be enabled")
+ }
+
+ node := control.Node{
+ ID: nodeID,
+ Name: generateQuickNodeName(region, nodeKind, host),
+ Provider: provider,
+ Region: region,
+ Host: host,
+ ACMEEmail: acmeEmail,
+ Enabled: true,
+ SSH: control.SSHConfig{
+ User: "root",
+ Port: 22,
+ Auth: "password",
+ PasswordEnv: "VPNEM_RUNTIME_PASSWORD",
+ Password: password,
+ },
+ Protocols: protocols,
+ Metadata: map[string]string{
+ "provision_mode": "quick",
+ },
+ }
+ return node, password, nil
+}
+
+func (h *Handler) provisionNodeFlow(ctx context.Context, node *control.Node, sshPassword string) (map[string]any, error) {
+ response := map[string]any{
+ "node_id": node.ID,
+ }
+ if sshPassword != "" {
+ node.SSH.Password = sshPassword
+ }
+
+ if strings.TrimSpace(node.Domain) == "" && nodeNeedsProvisionedDNS(*node) {
+ client := control.PorkbunClient{
+ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")),
+ SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")),
+ }
+ fqdn, err := client.EnsureRandomARecord(ctx, "em-sysadmin.xyz", dnsPrefixForNode(*node), strings.TrimSpace(node.Host), 600)
+ if err != nil {
+ return nil, err
+ }
+
+ node.Domain = fqdn
+ for idx := range node.Protocols {
+ if node.Protocols[idx].TLS != nil && node.Protocols[idx].TLS.Enabled {
+ node.Protocols[idx].TLS.ServerName = fqdn
+ }
+ }
+ if node.Metadata == nil {
+ node.Metadata = map[string]string{}
+ }
+ node.Metadata["dns_zone"] = "em-sysadmin.xyz"
+ node.Metadata["dns_provider"] = "porkbun"
+ if _, err := control.SaveNodeFile(h.inventoryDir(), *node); err != nil {
+ return nil, err
+ }
+
+ now := time.Now().UTC()
+ state, _ := control.LoadNodeState(h.stateDir(), node.ID)
+ if state == nil {
+ state = &control.NodeState{
+ NodeID: node.ID,
+ PublicHost: fqdn,
+ Services: []control.ServiceStatus{},
+ Metadata: map[string]any{},
+ }
+ }
+ state.PublicHost = fqdn
+ state.LastDNSSyncAt = &now
+ if state.Metadata == nil {
+ state.Metadata = map[string]any{}
+ }
+ state.Metadata["dns_provider"] = "porkbun"
+ state.Metadata["dns_zone"] = "em-sysadmin.xyz"
+ state.Metadata["dns_fqdn"] = fqdn
+ if err := control.SaveNodeState(h.stateDir(), *state); err != nil {
+ return nil, err
+ }
+ response["dns"] = map[string]any{
+ "provisioned": true,
+ "fqdn": fqdn,
+ }
+ } else {
+ response["dns"] = map[string]any{
+ "provisioned": false,
+ "fqdn": node.Domain,
+ "skipped": dnsSkipReason(*node),
+ }
+ }
+
+ savedPath, err := control.SaveNodeFile(h.inventoryDir(), *node)
+ if err != nil {
+ return nil, err
+ }
+ savedNode, err := control.LoadNodeFile(savedPath)
+ if err != nil {
+ return nil, err
+ }
+ *node = *savedNode
+ if sshPassword != "" {
+ node.SSH.Password = sshPassword
+ }
+
+ bootstrapState, err := control.BootstrapNode(ctx, control.SSHRunner{}, *node, control.BootstrapOptions{
+ StateDir: h.stateDir(),
+ DryRun: false,
+ })
+ if err != nil {
+ return nil, err
+ }
+ response["bootstrap"] = bootstrapState
+
+ checkState, err := control.CheckNode(ctx, control.SSHRunner{}, *node, h.stateDir())
+ if err != nil {
+ return nil, err
+ }
+ response["check"] = checkState
+
+ published := false
+ if canPublishNodeState(*checkState) {
+ inventory, err := control.LoadInventoryDir(h.inventoryDir())
+ if err != nil {
+ return nil, err
+ }
+ states := make(map[string]*control.NodeState, len(inventory.Nodes))
+ for _, item := range inventory.Nodes {
+ state, err := control.LoadNodeState(h.stateDir(), item.ID)
+ if err != nil {
+ if control.IsNotExist(err) {
+ continue
+ }
+ return nil, err
+ }
+ states[item.ID] = state
+ }
+ publishable := control.PublishableNodes(inventory.Nodes, states)
+ target := filepath.Join(h.store.DataDir(), "servers.json")
+ if err := control.WriteLegacyCatalog(target, publishable); err != nil {
+ return nil, err
+ }
+ catalogTarget := filepath.Join(h.store.DataDir(), "catalog-v2.json")
+ if err := control.WriteCatalogV2(catalogTarget, publishable, states); err != nil {
+ return nil, err
+ }
+ published = true
+ response["publish"] = map[string]any{
+ "published": true,
+ "target": target,
+ "catalog_v2_target": catalogTarget,
+ "count": len(publishable),
+ }
+ } else {
+ response["publish"] = map[string]any{
+ "published": false,
+ "reason": "узел ещё не готов и не healthy",
+ }
+ }
+ response["ready_for_catalog"] = published
+ return response, nil
+}
+
+func controlRandomHex(size int) (string, error) { return control.RandomHexForAPI(size) }
+func controlRandomBase64(size int) (string, error) { return control.RandomBase64ForAPI(size) }
+func controlRandomUUID() (string, error) { return control.RandomUUIDForAPI() }
+
+func nodeNeedsProvisionedDNS(node control.Node) bool {
+ for _, protocol := range node.Protocols {
+ if !protocol.Enabled {
+ continue
+ }
+ switch protocol.Type {
+ case "vless":
+ if protocol.TLS != nil && protocol.TLS.Enabled {
+ return true
+ }
+ case "vmess":
+ if protocol.TLS != nil && protocol.TLS.Enabled {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func dnsSkipReason(node control.Node) string {
+ if strings.TrimSpace(node.Domain) != "" {
+ return "домен уже задан"
+ }
+ return "selected protocols do not require a public domain"
+}
+
+const vpnUIHTML = `<!doctype html>
+<html lang="ru">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>Панель управления vpnem</title>
+ <style>
+ :root {
+ --bg: #f3efe7;
+ --panel: rgba(255, 251, 245, 0.82);
+ --ink: #1f2430;
+ --muted: #66645f;
+ --line: rgba(132, 110, 82, 0.18);
+ --accent: #0f766e;
+ --accent-2: #c2410c;
+ --accent-soft: rgba(15, 118, 110, 0.1);
+ }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0;
+ font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
+ color: var(--ink);
+ background:
+ radial-gradient(circle at top left, rgba(15, 118, 110, 0.16), transparent 24%),
+ radial-gradient(circle at 85% 10%, rgba(194, 65, 12, 0.14), transparent 20%),
+ radial-gradient(circle at bottom right, rgba(15, 118, 110, 0.08), transparent 28%),
+ var(--bg);
+ min-height: 100vh;
+ }
+ .shell {
+ max-width: 1360px;
+ margin: 0 auto;
+ padding: 18px 18px 32px;
+ }
+ @keyframes riseIn {
+ from { opacity: 0; transform: translateY(14px); }
+ to { opacity: 1; transform: translateY(0); }
+ }
+ @keyframes glowShift {
+ 0% { transform: translate3d(0, 0, 0) scale(1); }
+ 50% { transform: translate3d(10px, -8px, 0) scale(1.04); }
+ 100% { transform: translate3d(0, 0, 0) scale(1); }
+ }
+ .hero {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ align-items: flex-start;
+ margin-bottom: 18px;
+ padding: 20px;
+ border: 1px solid var(--line);
+ border-radius: 24px;
+ background:
+ linear-gradient(145deg, rgba(255,255,255,0.92), rgba(255,248,240,0.84)),
+ var(--panel);
+ box-shadow: 0 24px 70px rgba(31, 36, 48, 0.08);
+ position: relative;
+ overflow: hidden;
+ animation: riseIn .5s ease both;
+ }
+ .hero::after {
+ content: "";
+ position: absolute;
+ width: 280px;
+ height: 280px;
+ right: -80px;
+ top: -100px;
+ border-radius: 999px;
+ background: radial-gradient(circle, rgba(15,118,110,0.16), rgba(15,118,110,0));
+ pointer-events: none;
+ animation: glowShift 8s ease-in-out infinite;
+ }
+ h1 {
+ margin: 0;
+ font-size: 36px;
+ line-height: 0.98;
+ letter-spacing: -0.04em;
+ max-width: 780px;
+ }
+ .hero-copy {
+ display: grid;
+ gap: 10px;
+ max-width: 780px;
+ position: relative;
+ z-index: 1;
+ }
+ .hero-kicker {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 7px 12px;
+ border-radius: 999px;
+ background: rgba(15, 118, 110, 0.1);
+ color: var(--accent);
+ font-size: 12px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ width: fit-content;
+ }
+ .sub {
+ color: var(--muted);
+ max-width: 760px;
+ line-height: 1.48;
+ font-size: 14px;
+ }
+ .hero-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 4px;
+ }
+ .hero-rail {
+ display: grid;
+ gap: 10px;
+ min-width: 270px;
+ position: relative;
+ z-index: 1;
+ }
+ .hero-note {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 11px 13px;
+ background: rgba(255,255,255,0.76);
+ font-size: 12px;
+ color: var(--muted);
+ line-height: 1.38;
+ backdrop-filter: blur(10px);
+ }
+ .hero-note strong {
+ color: var(--ink);
+ font-size: 13px;
+ }
+ .workspace-grid {
+ display: grid;
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+ }
+ .panel {
+ background: var(--panel);
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ padding: 14px;
+ box-shadow: 0 14px 44px rgba(31, 36, 48, 0.07);
+ backdrop-filter: blur(10px);
+ animation: riseIn .5s ease both;
+ }
+ .panel-shell {
+ display: grid;
+ gap: 16px;
+ }
+ .section-shell {
+ display: grid;
+ gap: 12px;
+ }
+ .surface-head {
+ display: flex;
+ justify-content: space-between;
+ gap: 12px;
+ align-items: flex-start;
+ margin-bottom: 4px;
+ }
+ .surface-title {
+ margin: 0;
+ font-size: 22px;
+ letter-spacing: -0.03em;
+ }
+ .surface-sub {
+ margin: 4px 0 0;
+ color: var(--muted);
+ max-width: 720px;
+ line-height: 1.42;
+ font-size: 14px;
+ }
+ .panel h2 {
+ margin: 0 0 10px;
+ font-size: 17px;
+ }
+ .stack { display: grid; gap: 10px; }
+ .row { display: grid; gap: 8px; }
+ .cols-2 { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; }
+ .cols-3 { display: grid; gap: 10px; grid-template-columns: repeat(3, 1fr); }
+ label {
+ font-size: 13px;
+ color: var(--muted);
+ display: grid;
+ gap: 6px;
+ }
+ input, select, textarea, button {
+ font: inherit;
+ }
+ input, select, textarea {
+ width: 100%;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: #fff;
+ padding: 9px 11px;
+ color: var(--ink);
+ }
+ textarea { min-height: 88px; resize: vertical; }
+ button {
+ border: 0;
+ border-radius: 999px;
+ padding: 9px 14px;
+ cursor: pointer;
+ background: linear-gradient(135deg, #0f766e, #115e59);
+ color: #fff;
+ font-weight: 600;
+ box-shadow: 0 10px 24px rgba(15, 118, 110, 0.18);
+ transition: transform .16s ease, box-shadow .16s ease, border-color .16s ease, background .16s ease;
+ }
+ button:hover { transform: translateY(-1px); box-shadow: 0 14px 28px rgba(15, 118, 110, 0.22); }
+ button.alt { background: linear-gradient(135deg, #c2410c, #9a3412); }
+ button.warn { background: linear-gradient(135deg, #9a3412, #7c2d12); }
+ button.ghost {
+ background: rgba(255,255,255,0.58);
+ color: var(--ink);
+ border: 1px solid var(--line);
+ box-shadow: none;
+ }
+ button.ghost:hover {
+ background: rgba(255,255,255,0.92);
+ box-shadow: none;
+ }
+ .node-list {
+ display: grid;
+ gap: 6px;
+ max-height: 420px;
+ overflow: auto;
+ }
+ .node-card {
+ border: 1px solid var(--line);
+ background: rgba(255,255,255,0.8);
+ border-radius: 12px;
+ padding: 8px;
+ cursor: pointer;
+ transition: transform .12s ease, border-color .12s ease;
+ }
+ .node-card:hover { transform: translateY(-1px); border-color: var(--accent); }
+ .node-meta { color: var(--muted); font-size: 12px; margin-top: 3px; }
+ .protocol-box {
+ border: 1px dashed var(--line);
+ border-radius: 14px;
+ padding: 12px;
+ background: rgba(255,255,255,0.55);
+ }
+ .collapsible-card[open] summary {
+ margin-bottom: 12px;
+ }
+ .collapsible-card summary {
+ list-style: none;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ font-weight: 700;
+ color: var(--ink);
+ }
+ .collapsible-card summary::-webkit-details-marker { display: none; }
+ .summary-hint {
+ font-size: 12px;
+ color: var(--muted);
+ font-weight: 500;
+ }
+ .section-card {
+ border: 1px solid var(--line);
+ border-radius: 18px;
+ background: rgba(255,255,255,0.78);
+ padding: 14px;
+ }
+ .section-head {
+ margin-bottom: 10px;
+ }
+ .section-head h3 {
+ margin: 0 0 4px;
+ font-size: 17px;
+ color: var(--ink);
+ }
+ .section-head .tip {
+ margin: 0;
+ }
+ .toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ }
+ .toolbar button {
+ padding: 7px 12px;
+ font-size: 12px;
+ }
+ .toolbar button.ghost {
+ padding: 6px 11px;
+ }
+ .status {
+ min-height: 22px;
+ font-size: 14px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ padding: 10px 12px;
+ background: rgba(255,255,255,0.78);
+ color: var(--ink);
+ }
+ .status.info {
+ border-color: #bfdbfe;
+ background: #eff6ff;
+ }
+ .status.success {
+ border-color: #a7f3d0;
+ background: #ecfdf5;
+ }
+ .status.error {
+ border-color: #fdba74;
+ background: #fff7ed;
+ }
+ .tip {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.35;
+ }
+ .warn {
+ border-left: 3px solid var(--accent-2);
+ padding-left: 10px;
+ }
+ .checks {
+ display: grid;
+ gap: 10px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .preset-grid {
+ display: grid;
+ gap: 8px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .preset-card {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ padding: 11px 12px;
+ background: rgba(255,255,255,0.72);
+ cursor: pointer;
+ transition: transform .16s ease, border-color .16s ease, box-shadow .16s ease;
+ }
+ .preset-card:hover {
+ transform: translateY(-1px);
+ border-color: var(--accent);
+ }
+ .preset-card.active {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 2px rgba(15, 118, 110, 0.14);
+ background: rgba(240, 253, 250, 0.8);
+ }
+ .preset-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: var(--ink);
+ margin-bottom: 4px;
+ }
+ .preset-meta {
+ font-size: 12px;
+ color: var(--muted);
+ line-height: 1.32;
+ }
+ .check {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 12px;
+ background: #fff;
+ color: var(--ink);
+ }
+ .check input {
+ width: auto;
+ margin: 0;
+ }
+ .system-note {
+ display: grid;
+ gap: 8px;
+ }
+ .system-note {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(255,255,255,0.8);
+ padding: 12px;
+ }
+ .system-note .eyebrow {
+ font-size: 11px;
+ color: var(--muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ margin-bottom: 6px;
+ }
+ .system-note .value {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--ink);
+ line-height: 1.35;
+ }
+ .system-note .meta {
+ margin-top: 6px;
+ font-size: 12px;
+ color: var(--muted);
+ }
+ .node-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 10px;
+ }
+ .node-title {
+ font-size: 15px;
+ font-weight: 700;
+ color: var(--ink);
+ }
+ .badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-top: 4px;
+ }
+ .filter-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 5px;
+ margin-top: 2px;
+ align-items: flex-start;
+ align-content: flex-start;
+ }
+ .filter-chip {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex: 0 0 auto;
+ align-self: flex-start;
+ border-radius: 11px;
+ padding: 4px 9px;
+ background: rgba(255,255,255,0.78);
+ border: 1px solid rgba(148, 163, 184, 0.28);
+ color: #334155;
+ font-size: 11px;
+ font-weight: 600;
+ line-height: 1.05;
+ box-shadow: none;
+ min-height: 0;
+ white-space: nowrap;
+ }
+ .filter-chip:hover {
+ transform: none;
+ background: rgba(255,255,255,0.98);
+ border-color: rgba(15, 118, 110, 0.24);
+ box-shadow: none;
+ }
+ .filter-chip.alt {
+ background: linear-gradient(135deg, #c2410c, #9a3412);
+ border-color: transparent;
+ color: #fff;
+ box-shadow: 0 8px 18px rgba(154, 52, 18, 0.15);
+ }
+ .filter-chip.alt:hover {
+ transform: none;
+ box-shadow: 0 8px 18px rgba(154, 52, 18, 0.15);
+ }
+ .badge {
+ display: inline-flex;
+ align-items: center;
+ border-radius: 999px;
+ padding: 3px 7px;
+ font-size: 10px;
+ font-weight: 700;
+ line-height: 1;
+ }
+ .badge.ready {
+ background: #dcfce7;
+ color: #166534;
+ }
+ .badge.blocked {
+ background: #ffedd5;
+ color: #9a3412;
+ }
+ .badge.active {
+ background: #dbeafe;
+ color: #1d4ed8;
+ }
+ .badge.idle {
+ background: #e5e7eb;
+ color: #374151;
+ }
+ .badge.protocol {
+ background: #f3f4f6;
+ color: #334155;
+ font-weight: 600;
+ }
+ .list-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 12px;
+ margin-top: 6px;
+ }
+ .muted-box {
+ border: 1px dashed var(--line);
+ border-radius: 14px;
+ padding: 12px;
+ color: var(--muted);
+ background: rgba(255,255,255,0.45);
+ }
+ .empty-box {
+ padding: 10px 12px;
+ font-size: 12px;
+ line-height: 1.35;
+ }
+ .debug-box {
+ margin-top: 12px;
+ }
+ .debug-box summary {
+ cursor: pointer;
+ font-weight: 600;
+ }
+ .ready-grid {
+ display: grid;
+ gap: 10px;
+ }
+ .ready-card {
+ border: 1px solid var(--line);
+ border-radius: 16px;
+ background: rgba(255,255,255,0.88);
+ padding: 12px;
+ display: grid;
+ gap: 8px;
+ }
+ .ready-card-head {
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 12px;
+ }
+ .ready-card-title {
+ font-size: 16px;
+ font-weight: 700;
+ color: var(--ink);
+ }
+ .ready-card-sub {
+ font-size: 12px;
+ color: var(--muted);
+ margin-top: 2px;
+ }
+ .mono {
+ font-family: "IBM Plex Mono", "Cascadia Code", monospace;
+ font-size: 12px;
+ line-height: 1.5;
+ color: #243244;
+ background: #fff;
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ padding: 10px 12px;
+ overflow: auto;
+ word-break: break-all;
+ }
+ .settings-grid {
+ display: grid;
+ grid-template-columns: minmax(320px, 420px) minmax(0, 1fr);
+ gap: 14px;
+ align-items: start;
+ }
+ @media (max-width: 920px) {
+ .hero { flex-direction: column; }
+ .workspace-grid, .settings-grid { grid-template-columns: 1fr; }
+ .cols-2, .cols-3 { grid-template-columns: 1fr; }
+ .preset-grid,
+ .checks { grid-template-columns: 1fr; }
+ }
+ @media (max-width: 640px) {
+ }
+ </style>
+</head>
+<body>
+ <div class="shell">
+ <div class="hero">
+ <div class="hero-copy">
+ <div class="hero-kicker">Панель VPN</div>
+ <h1>Один экран для установки, ремонта и управления VPN-узлами.</h1>
+ <div class="sub">Сверху находится простой путь: вставьте IP сервера, введите root-пароль и получите готовый узел. Ниже остаётся тонкая настройка на тот случай, если нужно чинить, обновлять, добавлять SOCKS5 или вручную переопределять параметры.</div>
+ <div class="hero-actions">
+ <button id="jumpInstallBtn" type="button">Начать установку</button>
+ <button id="jumpAdvancedBtn" class="ghost" type="button">Открыть тонкую настройку</button>
+ </div>
+ </div>
+ <div class="hero-rail">
+ <div class="hero-note"><strong>Шаг 1</strong><br>Проверьте VPS и убедитесь, что панель говорит, можно ли ставить <strong>MULTI</strong> или <strong>SOCKS5</strong>.</div>
+ <div class="hero-note"><strong>Шаг 2</strong><br>Создайте узел, дождитесь проверки и сразу скопируйте готовую ссылку из карточек справа.</div>
+ <div class="hero-note"><strong>Тонкая настройка</strong><br>Если VPS уже под управлением, спускайтесь ниже: там есть обновление, ремонт, чистая переустановка и ручные override-поля.</div>
+ </div>
+ </div>
+
+ <div class="panel-shell">
+ <section id="quickStart" class="section-shell">
+ <div class="surface-head">
+ <div>
+ <h2 class="surface-title">Быстрая установка</h2>
+ <p class="surface-sub">Это главный путь. Если нужен просто рабочий сервер, оставайтесь здесь: сначала проверка VPS, потом установка, потом копирование готовых ссылок.</p>
+ </div>
+ </div>
+ <div class="workspace-grid">
+ <div class="stack">
+ <div class="panel stack">
+ <h2>Быстрая установка</h2>
+ <div class="tip">Это самый простой сценарий. Вставьте IP сервера, введите root-пароль, выберите тип узла и дождитесь, пока панель сама всё сделает: установит, проверит и покажет готовые ссылки.</div>
+ <div class="muted-box">
+ <strong>Как это работает</strong><br>
+ 1. Нажмите «Проверить VPS».<br>
+ 2. Оставьте включённым <strong>MULTI</strong>, если нужен основной современный режим.<br>
+ 3. Нажмите «Создать прокси».<br>
+ 4. Скопируйте готовую ссылку из блока ниже.
+ </div>
+ <div class="cols-2">
+ <label>Сервер (IP или домен)<input id="quickHost" placeholder="89.124.96.166"></label>
+ <label>Root-пароль<input id="quickRootPassword" type="password" placeholder="root-пароль"></label>
+ </div>
+ <details class="protocol-box collapsible-card">
+ <summary>Дополнительно <span class="summary-hint">Все поля уже заполнены по умолчанию</span></summary>
+ <div class="cols-3" style="margin-top:12px">
+ <label>Регион<input id="quickRegion" value="auto"></label>
+ <label>Провайдер<input id="quickProvider" value="custom-vps"></label>
+ <label>Email для ACME<input id="quickACMEEmail" value="admin@em-sysadmin.xyz"></label>
+ </div>
+ </details>
+ <div class="stack">
+ <strong>Выберите готовый сценарий</strong>
+ <div id="quickPresetGrid" class="preset-grid">
+ <button class="preset-card active" type="button" data-preset="multi">
+ <div class="preset-title">Обычный сервер</div>
+ <div class="preset-meta">Рекомендуется. TCP через REALITY, UDP через Hysteria2.</div>
+ </button>
+ <button class="preset-card" type="button" data-preset="multi+socks">
+ <div class="preset-title">Сервер + SOCKS5</div>
+ <div class="preset-meta">Основной MULTI-режим плюс fallback SOCKS5 на порту 54101.</div>
+ </button>
+ <button class="preset-card" type="button" data-preset="socks">
+ <div class="preset-title">Только SOCKS5</div>
+ <div class="preset-meta">Простой вариант без MULTI, только SOCKS5 на порту 54101.</div>
+ </button>
+ </div>
+ <div id="quickPresetSummary" class="tip">Сейчас выбран сценарий <strong>Обычный сервер</strong>.</div>
+ </div>
+ <div class="checks" style="display:none">
+ <label class="check"><input id="quickEnableMulti" type="checkbox" checked>MULTI (REALITY + Hysteria2)</label>
+ <label class="check"><input id="quickEnableSocks" type="checkbox">SOCKS5</label>
+ </div>
+ <div class="tip">Если не уверены, оставляйте пресет <strong>Обычный сервер</strong>. Для SOCKS5 по умолчанию используется порт <code>54101</code>.</div>
+ <div id="quickDefaults" class="muted-box">
+ <strong>Что заполнится автоматически</strong><br>
+ Имя сервера будет создано само.<br>
+ Для <strong>MULTI</strong> по умолчанию используются уже проверенные настройки: TCP через REALITY, UDP через Hysteria2, порт <code>443</code>, рабочий SNI и безопасные transport-параметры.<br>
+ Для <strong>SOCKS5</strong> по умолчанию используется порт <code>54101</code>.
+ </div>
+ <div id="quickHostStatus" class="muted-box" style="display:none"></div>
+ <div id="quickStatusRail" class="badges" style="display:none"></div>
+ <div id="quickGuide" class="muted-box" style="display:none"></div>
+ <div class="toolbar">
+ <button id="quickInspectBtn" class="ghost" type="button">Проверить VPS</button>
+ <button id="quickProvisionBtn" type="button">Создать прокси</button>
+ </div>
+ </div>
+ <div id="status" class="status info">Готово.</div>
+ <div class="tip warn">Если панель доступна публично, не отключайте админ-токен. Главная вкладка рассчитана на простой сценарий с IP и паролем.</div>
+ <div id="currentSystem" class="system-note">
+ <div class="eyebrow">Сейчас в системе</div>
+ <div id="currentSystemValue" class="value">Пока нет ни одного сервера.</div>
+ <div id="currentSystemMeta" class="meta">Начните с проверки VPS, затем создайте первый сервер и скопируйте готовую ссылку.</div>
+ </div>
+ </div>
+
+ <div class="stack">
+ <div class="panel stack">
+ <div class="toolbar" style="justify-content:space-between;align-items:center">
+ <h2 style="margin:0">Мои серверы</h2>
+ <div class="toolbar">
+ <button id="refreshBtn" class="ghost" type="button">Обновить</button>
+ <button id="publishBtn" class="ghost" type="button">Перепубликовать каталог</button>
+ </div>
+ </div>
+ <div id="fleetFilters" class="filter-chips">
+ <button class="ghost filter-chip" type="button" data-fleet-filter="all">Все</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="ready">Готовы</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="repair">Нужен ремонт</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="managed">Уже под управлением</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="multi">MULTI</button>
+ <button class="ghost filter-chip" type="button" data-fleet-filter="socks5">SOCKS5</button>
+ </div>
+ <div id="nodeList" class="node-list"></div>
+ </div>
+
+ <div class="panel stack">
+ <div class="toolbar" style="justify-content:space-between;align-items:center">
+ <h2 style="margin:0">Подключение</h2>
+ <button id="copySummaryBtn" class="ghost" type="button">Копировать сводку</button>
+ </div>
+ <div class="tip">Используйте этот блок после того, как узел станет healthy и готовым к публикации. Здесь находятся ссылки и параметры подключения для прямого копирования.</div>
+ <div id="readyCards" class="ready-grid"></div>
+ <details class="debug-box">
+ <summary>Сырая сводка</summary>
+ <pre id="summaryView" style="margin:12px 0 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#fff;overflow:auto;min-height:180px"></pre>
+ </details>
+ </div>
+ </div>
+ </div>
+ </section>
+
+ <section id="advancedControls" class="section-shell">
+ <div class="surface-head">
+ <div>
+ <h2 class="surface-title">Тонкая настройка и сервисные действия</h2>
+ <p class="surface-sub">Этот блок нужен, когда узел уже существует и его надо обновлять, ремонтировать, дооснащать SOCKS5 или вручную править конфиг.</p>
+ </div>
+ </div>
+ <div class="settings-grid">
+ <div class="stack">
+ <div class="panel">
+ <h2>Что здесь можно делать</h2>
+ <div class="tip">Выбирайте узел в списке выше и используйте основные действия. Ручные поля протоколов нужны редко и остаются ниже как сервисный слой.</div>
+ </div>
+ <details id="accessPanel" class="panel">
+ <summary style="cursor:pointer;font-weight:600">Доступ</summary>
+ <div class="row" style="margin-top:12px">
+ <label>Ключ доступа
+ <input id="adminToken" placeholder="Необязательно. Используется, когда задан VPNEM_ADMIN_TOKEN">
+ </label>
+ <div class="tip">Панель можно открывать по magic-link вроде ` + "`/vpnui/?token=...`" + ` или ` + "`/vpnui/#token=...`" + `. Ключ будет сохранён в этом браузере и удалён из URL.</div>
+ </div>
+ </details>
+ </div>
+
+ <div class="panel">
+ <form id="nodeForm" class="stack">
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Данные узла</h3>
+ <div class="tip">Базовые данные о VPS и публичном хосте, который будут использовать клиенты.</div>
+ </div>
+ <div class="cols-2">
+ <label>ID узла<input name="id" required></label>
+ <label>Название<input name="name" required></label>
+ </div>
+ <div class="cols-3">
+ <label>Провайдер<input name="provider" value="custom-vps"></label>
+ <label>Регион<input name="region" required placeholder="nl"></label>
+ <label>Включён
+ <select name="enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ </div>
+ <div class="cols-2">
+ <label>Хост<input name="host" required placeholder="203.0.113.10"></label>
+ <label>Домен<input name="domain" placeholder="nl-01.example.com"></label>
+ </div>
+ <div class="cols-2">
+ <label>Email для ACME<input name="acme_email" placeholder="admin@example.com"></label>
+ <div class="tip">Используйте настройки только тогда, когда простого сценария “Создать прокси” уже недостаточно и нужен точный контроль над узлом.</div>
+ </div>
+ </div>
+
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Доступ к серверу</h3>
+ <div class="tip">Как ` + "`vpnui`" + ` должен входить на VPS для bootstrap, проверок, обновления и удаления узла.</div>
+ </div>
+ <div class="cols-3">
+ <label>SSH-пользователь<input name="ssh_user" value="root" required></label>
+ <label>SSH-порт<input name="ssh_port" type="number" value="22" required></label>
+ <label>Тип SSH-входа
+ <select name="ssh_auth">
+ <option value="key">key</option>
+ <option value="password">password</option>
+ </select>
+ </label>
+ </div>
+ <div class="cols-2">
+ <label>Файл SSH-ключа<input name="ssh_identity" placeholder="~/.ssh/id_ed25519"></label>
+ <label>Переменная окружения с SSH-паролем<input name="ssh_password_env" placeholder="VPNEM_NODE_PASSWORD"></label>
+ </div>
+ <div class="cols-2">
+ <label>Временный SSH-пароль<input name="ssh_runtime_password" type="password" placeholder="Нужен только для bootstrap/check"></label>
+ <div class="tip">Если сервер использует вход по паролю, введите текущий root-пароль перед bootstrap или проверками. Он отправляется только вместе с действием и не сохраняется в inventory.</div>
+ </div>
+ </div>
+
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Основные действия</h3>
+ <div class="tip">Используйте эти кнопки для обычного обслуживания уже существующего узла. Сырые поля протоколов ниже обычно трогать не нужно.</div>
+ </div>
+ <div id="nodeStatusRail" class="badges" style="display:none"></div>
+ <div id="nodeGuide" class="muted-box">Выберите узел, чтобы увидеть самый безопасный следующий шаг.</div>
+ <div class="toolbar">
+ <button id="upgradeBtn" class="alt" type="button">Обновить сервер</button>
+ <button id="addSocks5Btn" class="alt" type="button">Добавить SOCKS5</button>
+ <button id="repairReinstallBtn" class="alt" type="button">Починить сервер</button>
+ <button id="cleanReinstallBtn" class="warn" type="button">Переустановить сервер</button>
+ <button id="checkBtn" class="alt" type="button">Проверить сервер</button>
+ </div>
+ <div class="tip">Используйте <strong>Добавить SOCKS5</strong>, когда MULTI-узел уже работает и вы хотите добавить fallback-прокси на порту 54101 на том же VPS.</div>
+ </div>
+
+ <details class="section-card stack collapsible-card">
+ <summary>Ручные переопределения протоколов <span class="summary-hint">Нужно только если вы точно знаете, что меняете</span></summary>
+ <div class="section-head" style="margin-top:12px">
+ <h3>Протоколы</h3>
+ <div class="tip">Этот раздел нужен только для ручных переопределений. В обычном сценарии достаточно быстрой установки на главной вкладке и основных действий выше.</div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>VLESS</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="vless_enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ <label>Порт<input name="vless_port" type="number" value="443"></label>
+ <label>UUID<input name="vless_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label>
+ </div>
+ <div class="cols-3">
+ <label>TLS включён
+ <select name="vless_tls_enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ <label>Имя сервера / SNI<input name="vless_server_name" placeholder="nl-01.example.com"></label>
+ <label>Тип транспорта
+ <select name="vless_transport_type">
+ <option value="">none</option>
+ <option value="ws">ws</option>
+ <option value="httpupgrade">httpupgrade</option>
+ <option value="grpc">grpc</option>
+ </select>
+ </label>
+ </div>
+ <label>Путь транспорта<input name="vless_path" placeholder="/ws"></label>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>VLESS REALITY</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="reality_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="reality_port" type="number" value="443"></label>
+ <label>UUID<input name="reality_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label>
+ </div>
+ <div class="cols-3">
+ <label>Имя сервера / SNI<input name="reality_server_name" placeholder="www.nokia.com"></label>
+ <label>Порт handshake<input name="reality_server_port" type="number" value="443"></label>
+ <label>Отпечаток браузера<input name="reality_fingerprint" placeholder="chrome"></label>
+ </div>
+ <div class="cols-3">
+ <label>Публичный ключ<input name="reality_public_key" placeholder="сгенерируется автоматически, если пусто"></label>
+ <label>Приватный ключ<input name="reality_private_key" placeholder="сгенерируется автоматически, если пусто"></label>
+ <label>Короткий ID<input name="reality_short_id" placeholder="сгенерируется автоматически, если пусто"></label>
+ </div>
+ <div class="tip">REALITY использует server-side reality TLS внутри sing-box и не требует публичный домен или ACME-сертификаты.</div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>Shadowsocks</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="ss_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="ss_port" type="number" value="8443"></label>
+ <label>Метод<input name="ss_method" placeholder="2022-blake3-aes-128-gcm"></label>
+ </div>
+ <label>Пароль<input name="ss_password" placeholder="secret"></label>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>SOCKS5</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="socks_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="socks_port" type="number" value="1080"></label>
+ <div class="tip">Простой прямой SOCKS5 listener без TLS-слоя.</div>
+ </div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>VMess</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="vmess_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="vmess_port" type="number" value="443"></label>
+ <label>UUID<input name="vmess_uuid" placeholder="00000000-0000-0000-0000-000000000000"></label>
+ </div>
+ <div class="cols-3">
+ <label>TLS включён
+ <select name="vmess_tls_enabled">
+ <option value="true">true</option>
+ <option value="false">false</option>
+ </select>
+ </label>
+ <label>Имя сервера / SNI<input name="vmess_server_name" placeholder="vmess.example.com"></label>
+ <label>Путь<input name="vmess_path" placeholder="/vmess"></label>
+ </div>
+ </div>
+
+ <div class="protocol-box stack">
+ <strong>Hysteria2</strong>
+ <div class="cols-3">
+ <label>Включён
+ <select name="hy2_enabled">
+ <option value="false">false</option>
+ <option value="true">true</option>
+ </select>
+ </label>
+ <label>Порт<input name="hy2_port" type="number" value="8443"></label>
+ <label>Пароль<input name="hy2_password" placeholder="hy2-secret"></label>
+ </div>
+ <div class="cols-3">
+ <label>Скорость вверх, Mbps<input name="hy2_up_mbps" type="number" placeholder="необязательно"></label>
+ <label>Скорость вниз, Mbps<input name="hy2_down_mbps" type="number" placeholder="необязательно"></label>
+ <label>Пароль obfs<input name="hy2_obfs_password" placeholder="необязательно"></label>
+ </div>
+ <div class="cols-2">
+ <label>Путь к TLS-сертификату<input name="hy2_tls_cert_path" placeholder="/opt/vpnem-node/certs/fullchain.pem"></label>
+ <label>Путь к TLS-ключу<input name="hy2_tls_key_path" placeholder="/opt/vpnem-node/certs/privkey.pem"></label>
+ </div>
+ </div>
+ </details>
+
+ <div class="section-card stack">
+ <div class="section-head">
+ <h3>Сохранение и жизненный цикл</h3>
+ <div class="tip">Сохраняйте ручные изменения только тогда, когда вы специально меняли конфигурацию узла. Ниже остаются расширенные действия для DNS и разрушительных операций.</div>
+ </div>
+ <div class="toolbar">
+ <button type="submit">Сохранить узел</button>
+ <button id="resetBtn" class="ghost" type="button">Сбросить</button>
+ </div>
+ <details class="protocol-box">
+ <summary style="cursor:pointer;font-weight:600">Расширенные действия</summary>
+ <div class="toolbar" style="margin-top:12px">
+ <button id="enableNodeBtn" class="ghost" type="button">Включить узел</button>
+ <button id="disableNodeBtn" class="ghost" type="button">Выключить узел</button>
+ <button id="rotateSecretsBtn" class="ghost" type="button">Сменить секреты</button>
+ <button id="provisionDnsBtn" class="ghost" type="button">Создать DNS</button>
+ <button id="provisionNodeBtn" class="alt" type="button">Подготовить узел</button>
+ <button id="deleteDnsBtn" class="ghost" type="button">Удалить DNS</button>
+ <button id="bootstrapDryRunBtn" class="ghost" type="button">Bootstrap без запуска</button>
+ <button id="bootstrapBtn" type="button">Запустить bootstrap</button>
+ <button id="destroyNodeBtn" class="warn" type="button">Удалить сервер</button>
+ </div>
+ </details>
+ </div>
+ </form>
+ <details class="debug-box">
+ <summary>Техническая диагностика</summary>
+ <pre id="stateView" style="margin:12px 0 0;padding:14px;border:1px solid var(--line);border-radius:14px;background:#fff;overflow:auto;min-height:180px"></pre>
+ </details>
+ </div>
+ </div>
+ </section>
+ </div>
+ </div>
+
+ <script>
+ const state = { nodes: [], states: {}, publishDecisions: {}, selectedNodeID: '', fleetFilter: 'all' };
+ const tokenInput = document.getElementById('adminToken');
+ const statusEl = document.getElementById('status');
+ const nodeListEl = document.getElementById('nodeList');
+ const fleetFiltersEl = document.getElementById('fleetFilters');
+ const stateViewEl = document.getElementById('stateView');
+ const summaryViewEl = document.getElementById('summaryView');
+ const readyCardsEl = document.getElementById('readyCards');
+ const currentSystemValueEl = document.getElementById('currentSystemValue');
+ const currentSystemMetaEl = document.getElementById('currentSystemMeta');
+ const form = document.getElementById('nodeForm');
+ const quickHostEl = document.getElementById('quickHost');
+ const quickRootPasswordEl = document.getElementById('quickRootPassword');
+ const quickRegionEl = document.getElementById('quickRegion');
+ const quickProviderEl = document.getElementById('quickProvider');
+ const quickACMEEmailEl = document.getElementById('quickACMEEmail');
+ const quickEnableMultiEl = document.getElementById('quickEnableMulti');
+ const quickEnableSocksEl = document.getElementById('quickEnableSocks');
+ const quickPresetGridEl = document.getElementById('quickPresetGrid');
+ const quickPresetSummaryEl = document.getElementById('quickPresetSummary');
+ const quickHostStatusEl = document.getElementById('quickHostStatus');
+ const quickStatusRailEl = document.getElementById('quickStatusRail');
+ const quickGuideEl = document.getElementById('quickGuide');
+ const quickInspectBtn = document.getElementById('quickInspectBtn');
+ const jumpInstallBtn = document.getElementById('jumpInstallBtn');
+ const jumpAdvancedBtn = document.getElementById('jumpAdvancedBtn');
+ const quickStartEl = document.getElementById('quickStart');
+ const advancedControlsEl = document.getElementById('advancedControls');
+ const accessPanelEl = document.getElementById('accessPanel');
+ const nodeStatusRailEl = document.getElementById('nodeStatusRail');
+ const nodeGuideEl = document.getElementById('nodeGuide');
+
+ function readTokenFromLocation() {
+ const url = new URL(window.location.href);
+ const queryToken = (url.searchParams.get('token') || '').trim();
+ if (queryToken) {
+ url.searchParams.delete('token');
+ history.replaceState({}, '', url.pathname + (url.searchParams.toString() ? '?' + url.searchParams.toString() : '') + url.hash.replace(/^#token=.*$/, ''));
+ return queryToken;
+ }
+ const hash = window.location.hash || '';
+ if (hash.startsWith('#token=')) {
+ const hashToken = hash.slice('#token='.length).trim();
+ history.replaceState({}, '', url.pathname + (url.search ? url.search : ''));
+ return hashToken;
+ }
+ return '';
+ }
+
+ const bootToken = readTokenFromLocation() || localStorage.getItem('vpnem_admin_token') || '';
+ tokenInput.value = bootToken;
+ if (bootToken) {
+ localStorage.setItem('vpnem_admin_token', bootToken);
+ accessPanelEl.open = false;
+ }
+ tokenInput.addEventListener('change', () => {
+ const token = tokenInput.value.trim();
+ localStorage.setItem('vpnem_admin_token', token);
+ accessPanelEl.open = !token;
+ });
+
+ function setStatus(text, tone = 'info') {
+ statusEl.textContent = text;
+ statusEl.className = 'status ' + tone;
+ }
+
+ function scrollToSection(el) {
+ if (!el) return;
+ el.scrollIntoView({ behavior: 'smooth', block: 'start' });
+ }
+
+ function openAdvancedControls() {
+ scrollToSection(advancedControlsEl);
+ }
+
+ function describeQuickPreset(preset) {
+ if (preset === 'multi+socks') return 'Сейчас выбран сценарий <strong>Сервер + SOCKS5</strong>.';
+ if (preset === 'socks') return 'Сейчас выбран сценарий <strong>Только SOCKS5</strong>.';
+ return 'Сейчас выбран сценарий <strong>Обычный сервер</strong>.';
+ }
+
+ function setQuickPreset(preset) {
+ const value = preset || 'multi';
+ quickEnableMultiEl.checked = value === 'multi' || value === 'multi+socks';
+ quickEnableSocksEl.checked = value === 'socks' || value === 'multi+socks';
+ if (quickPresetGridEl) {
+ quickPresetGridEl.querySelectorAll('[data-preset]').forEach(el => {
+ el.classList.toggle('active', el.getAttribute('data-preset') === value);
+ });
+ }
+ if (quickPresetSummaryEl) {
+ quickPresetSummaryEl.innerHTML = describeQuickPreset(value);
+ }
+ }
+
+ function updateCurrentSystem() {
+ const nodes = state.nodes || [];
+ const readyCount = nodes.filter(node => state.publishDecisions[node.id]?.eligible).length;
+ const blockedCount = nodes.filter(node => state.publishDecisions[node.id] && !state.publishDecisions[node.id].eligible).length;
+ const selected = nodes.find(node => node.id === state.selectedNodeID);
+ if (!nodes.length) {
+ currentSystemValueEl.textContent = 'Пока нет ни одного сервера.';
+ currentSystemMetaEl.textContent = 'Начните с проверки VPS, затем создайте первый сервер и скопируйте готовую ссылку.';
+ return;
+ }
+ currentSystemValueEl.textContent = 'Сохранено ' + String(nodes.length) + ' серверов. Из них ' + String(readyCount) + ' уже готовы к публикации, а ' + String(blockedCount) + ' требуют внимания.';
+ if (!selected) {
+ currentSystemMetaEl.textContent = 'Выберите любой сервер справа, и панель подскажет самый безопасный следующий шаг.';
+ return;
+ }
+ const selectedState = state.states[selected.id];
+ const selectedDecision = state.publishDecisions[selected.id];
+ const lifecycle = nodeLifecycleLabel(selected, selectedState, selectedDecision);
+ const publishLabel = selectedDecision?.eligible ? 'уже можно выдавать пользователям' : 'пока нужен дополнительный шаг';
+ currentSystemMetaEl.textContent = 'Сейчас выбран сервер «' + (selected.name || selected.id || 'Без имени') + '»: статус — ' + lifecycle.label + ', публикация — ' + publishLabel + '.';
+ }
+
+ function nodeProductState(node, runtimeState, decision) {
+ const status = String(runtimeState?.bootstrap_status || 'new');
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2');
+ const hasSocks = protocols.includes('socks5') || protocols.includes('socks');
+ if (node.enabled === false) {
+ return { title: 'Сервер выключен', subtitle: 'Он сохранён, но сейчас не публикуется.', nextStep: 'Включите его снова или удалите, если он больше не нужен.' };
+ }
+ if (status === 'healthy' || status === 'ready') {
+ if (decision?.eligible) {
+ if (hasMulti && !hasSocks) {
+ return { title: 'Сервер работает', subtitle: 'Основной MULTI уже поднят и ссылки готовы.', nextStep: 'При желании можно добавить SOCKS5 как запасной вариант.' };
+ }
+ if (hasMulti && hasSocks) {
+ return { title: 'Сервер полностью готов', subtitle: 'MULTI и SOCKS5 уже работают на одном VPS.', nextStep: 'Обычно здесь ничего делать не нужно.' };
+ }
+ if (hasSocks && !hasMulti) {
+ return { title: 'SOCKS5 сервер готов', subtitle: 'Прокси-сервер уже работает и доступен.', nextStep: 'Открывайте карточку чтобы скопировать ссылку или выполнить ремонт.' };
+ }
+ return { title: 'Сервер готов', subtitle: 'Его уже можно использовать и публиковать пользователям.', nextStep: 'Открывайте карточку только если нужен ремонт или переустановка.' };
+ }
+ return { title: 'Сервер почти готов', subtitle: 'Runtime уже работает, но публикация пока заблокирована.', nextStep: 'Откройте сервер и выполните рекомендуемое действие из подсказки ниже.' };
+ }
+ if (status === 'failed' || status === 'unreachable') {
+ return { title: 'Нужен ремонт', subtitle: 'Сервер сохранён, но сейчас не проходит проверку.', nextStep: 'Самое безопасное действие — «Починить сервер».' };
+ }
+ if (status === 'planned' || status === 'pending' || status === 'reachable') {
+ return { title: 'Идёт установка', subtitle: 'VPS уже в процессе настройки и проверки.', nextStep: 'Подождите немного или откройте карточку, если нужно повторить установку.' };
+ }
+ return { title: 'Сервер ещё не установлен', subtitle: 'Карточка сохранена, но runtime на VPS ещё не развёрнут.', nextStep: 'Откройте сервер и запустите установку или обновление.' };
+ }
+
+ function renderGuideBox(el, title, lines, tone = 'info') {
+ if (!el) return;
+ const safeLines = (lines || []).filter(Boolean);
+ if (!title && !safeLines.length) {
+ el.style.display = 'none';
+ el.innerHTML = '';
+ el.style.borderColor = 'var(--line)';
+ el.style.background = '#fff';
+ el.style.color = '';
+ return;
+ }
+ el.style.display = 'block';
+ el.className = 'muted-box';
+ el.style.borderColor = 'var(--line)';
+ el.style.background = '#fff';
+ el.style.color = '';
+ if (tone === 'warn') {
+ el.style.borderColor = '#f59e0b';
+ el.style.background = '#fffbeb';
+ el.style.color = '#92400e';
+ } else if (tone === 'danger') {
+ el.style.borderColor = '#dc2626';
+ el.style.background = '#fef2f2';
+ el.style.color = '#991b1b';
+ }
+ const titleHTML = title ? '<strong>' + title + '</strong>' : '';
+ const bodyHTML = safeLines.map(line => '<div>' + line + '</div>').join('');
+ el.innerHTML = titleHTML + bodyHTML;
+ }
+
+ function renderStatusRail(el, items) {
+ if (!el) return;
+ const safeItems = (items || []).filter(item => item && item.label);
+ if (!safeItems.length) {
+ el.style.display = 'none';
+ el.innerHTML = '';
+ return;
+ }
+ el.style.display = 'flex';
+ el.innerHTML = safeItems.map(item => '<span class="badge ' + (item.tone || 'idle') + '">' + item.label + '</span>').join('');
+ }
+
+ function quickStatusItems(data, existingNode) {
+ if (existingNode) {
+ const protocols = (existingNode.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const items = [{ label: 'Уже под управлением', tone: 'blocked' }];
+ if (protocols.includes('vless-reality') && protocols.includes('hysteria2')) items.push({ label: 'MULTI уже есть', tone: 'ready' });
+ if (protocols.includes('socks5') || protocols.includes('socks')) items.push({ label: 'SOCKS5 уже есть', tone: 'ready' });
+ return items;
+ }
+ if (!data) return [];
+ const items = [];
+ if (data.already_managed) items.push({ label: 'Уже под управлением', tone: 'blocked' });
+ if (data.quick_multi?.supported) items.push({ label: 'Можно ставить MULTI', tone: 'ready' });
+ else if (data.quick_multi) items.push({ label: 'MULTI заблокирован', tone: 'blocked' });
+ if (data.quick_socks5?.supported) items.push({ label: 'Можно ставить SOCKS5', tone: 'ready' });
+ else if (data.quick_socks5) items.push({ label: 'SOCKS5 заблокирован', tone: 'blocked' });
+ if (data.support_tier === 'recommended') items.push({ label: 'Рекомендуемый дистрибутив', tone: 'ready' });
+ else if (data.support_tier === 'supported') items.push({ label: 'Поддерживаемый дистрибутив', tone: 'active' });
+ else if (data.support_tier === 'experimental') items.push({ label: 'Экспериментальный дистрибутив', tone: 'blocked' });
+ return items;
+ }
+
+ function selectedNodeStatusItems(node, runtimeState, decision) {
+ if (!node) return [];
+ const items = [];
+ const lifecycle = nodeLifecycleLabel(node, runtimeState, decision);
+ items.push({ label: lifecycle.label, tone: lifecycle.tone });
+ if (decision?.eligible) items.push({ label: 'Готов к публикации', tone: 'ready' });
+ else if (decision) items.push({ label: 'Публикация заблокирована', tone: 'blocked' });
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ if (protocols.includes('vless-reality') && protocols.includes('hysteria2')) items.push({ label: 'MULTI', tone: 'active' });
+ if (protocols.includes('socks5') || protocols.includes('socks')) items.push({ label: 'SOCKS5', tone: 'active' });
+ return items;
+ }
+
+ function nodeMatchesFleetFilter(node) {
+ const filter = state.fleetFilter || 'all';
+ if (filter === 'all') return true;
+ const nodeState = state.states[node.id];
+ const decision = state.publishDecisions[node.id];
+ const lifecycle = nodeLifecycleLabel(node, nodeState, decision);
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2');
+ const hasSocks = protocols.includes('socks5') || protocols.includes('socks');
+ if (filter === 'ready') return lifecycle.label === 'Готов';
+ if (filter === 'repair') return lifecycle.label === 'Нужен ремонт' || lifecycle.label === 'Нужно внимание';
+ if (filter === 'managed') return true;
+ if (filter === 'multi') return hasMulti;
+ if (filter === 'socks5') return hasSocks;
+ return true;
+ }
+
+ function renderFleetFilters() {
+ if (!fleetFiltersEl) return;
+ fleetFiltersEl.querySelectorAll('[data-fleet-filter]').forEach(btn => {
+ const active = btn.getAttribute('data-fleet-filter') === state.fleetFilter;
+ btn.classList.toggle('alt', active);
+ btn.classList.toggle('ghost', !active);
+ });
+ }
+
+ function selectedNodeGuide(node, runtimeState, decision) {
+ if (!node) {
+ return {
+ title: 'Что можно сделать здесь',
+ tone: 'info',
+ lines: [
+ 'Сначала выберите узел в списке.',
+ 'Затем используйте основные действия, а не редактируйте поля вручную.',
+ ]
+ };
+ }
+ const status = String(runtimeState?.bootstrap_status || 'new');
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type);
+ const hasMulti = protocols.includes('vless-reality') && protocols.includes('hysteria2');
+ const hasSocks = protocols.includes('socks5') || protocols.includes('socks');
+ if (node.enabled === false) {
+ return {
+ title: 'Этот узел выключен.',
+ tone: 'warn',
+ lines: [
+ 'Используйте «Включить узел», если хотите снова публиковать его.',
+ 'Используйте «Удалить сервер» только если хотите убрать его полностью.',
+ ]
+ };
+ }
+ if (status === 'healthy' || status === 'ready') {
+ const lines = ['Сейчас всё выглядит исправно. Используйте «Обновить сервер», если хотите применить актуальный bundle без смены секретов.'];
+ if (hasMulti && !hasSocks) {
+ lines.push('Используйте «Добавить SOCKS5», если хотите добавить fallback-прокси на порту 54101 на том же VPS.');
+ }
+ if (decision && decision.eligible === false) {
+ lines.push('Этот узел всё ещё заблокирован для публикации. Проверьте причины и затем нажмите «Починить сервер», если нужно.');
+ return { title: 'Узел работает, но ему нужно внимание.', tone: 'warn', lines: lines };
+ }
+ lines.push('Используйте «Переустановить сервер» только если хотите новые секреты и полный redeploy.');
+ return { title: 'Узел готов.', tone: 'info', lines: lines };
+ }
+ if (status === 'failed' || status === 'unreachable') {
+ return {
+ title: 'Узел требует ремонта.',
+ tone: 'danger',
+ lines: [
+ 'Сначала используйте «Починить сервер». Это сохранит текущую identity и заново развернёт runtime.',
+ 'Используйте «Переустановить сервер» только если ремонта недостаточно и нужны новые секреты.',
+ ]
+ };
+ }
+ if (status === 'planned' || status === 'pending' || status === 'reachable') {
+ return {
+ title: 'Узел ещё не развёрнут полностью.',
+ tone: 'warn',
+ lines: [
+ 'Используйте Bootstrap или «Подготовить узел», чтобы завершить установку.',
+ 'Затем запустите проверку перед публикацией ссылок пользователям.',
+ ]
+ };
+ }
+ return {
+ title: 'Узел сохранён, но ещё не развёрнут.',
+ tone: 'info',
+ lines: [
+ 'Используйте Bootstrap, чтобы установить runtime на VPS.',
+ hasMulti && !hasSocks ? 'Позже используйте «Добавить SOCKS5», если захотите добавить fallback-прокси.' : '',
+ ]
+ };
+ }
+
+ function nodeLifecycleLabel(node, runtimeState, decision) {
+ if (node && node.enabled === false) return { label: 'Выключен', tone: 'idle' };
+ const status = String(runtimeState?.bootstrap_status || 'new');
+ if (status === 'healthy' || status === 'ready') return { label: 'Готов', tone: 'ready' };
+ if (status === 'failed' || status === 'unreachable') return { label: 'Нужен ремонт', tone: 'blocked' };
+ if (status === 'planned' || status === 'pending' || status === 'reachable') return { label: 'Устанавливается', tone: 'active' };
+ if (decision && decision.eligible === false) return { label: 'Нужно внимание', tone: 'blocked' };
+ return { label: 'Не развёрнут', tone: 'idle' };
+ }
+
+ function encodeBase64(value) {
+ return btoa(unescape(encodeURIComponent(value)));
+ }
+
+ function buildVmessLink(node, protocol) {
+ const payload = {
+ v: '2',
+ ps: node.id || node.name || 'vmess',
+ add: node.domain || node.host || '',
+ port: String(protocol.port || 0),
+ id: String(protocol.auth?.uuid || ''),
+ aid: '0',
+ scy: 'auto',
+ net: 'ws',
+ type: 'none',
+ host: String(protocol.tls?.server_name || node.domain || ''),
+ path: String(protocol.extra?.path || '/vmess'),
+ tls: protocol.tls?.enabled ? 'tls' : '',
+ sni: String(protocol.tls?.server_name || node.domain || '')
+ };
+ return 'vmess://' + encodeBase64(JSON.stringify(payload));
+ }
+
+ function buildShadowsocksLink(node, protocol) {
+ const userinfo = encodeBase64(String(protocol.auth?.method || '') + ':' + String(protocol.auth?.password || ''));
+ return 'ss://' + userinfo + '@' + (node.domain || node.host || '') + ':' + String(protocol.port || 0) + '#' + encodeURIComponent(node.id || 'shadowsocks');
+ }
+
+ function buildHysteria2Link(node, protocol) {
+ const params = new URLSearchParams();
+ params.set('sni', String(node.domain || protocol.tls?.server_name || ''));
+ if (protocol.extra?.obfs_password) {
+ params.set('obfs', 'salamander');
+ params.set('obfs-password', String(protocol.extra.obfs_password));
+ }
+ params.set('insecure', '1');
+ return 'hysteria2://' + encodeURIComponent(String(protocol.auth?.password || '')) + '@' + (node.domain || node.host || '') + ':' + String(protocol.port || 0) + '/?' + params.toString() + '#' + encodeURIComponent(node.id || 'hysteria2');
+ }
+
+ function protocolCards(node, runtimeState) {
+ if (!node) return [];
+ const host = node.domain || node.host || '';
+ return (node.protocols || []).filter(p => p.enabled).map(protocol => {
+ const titleMap = {
+ 'socks5': 'SOCKS5',
+ 'socks': 'SOCKS5',
+ 'vless-reality': 'VLESS REALITY'
+ };
+ const card = {
+ type: protocol.type,
+ title: titleMap[protocol.type] || protocol.type.charAt(0).toUpperCase() + protocol.type.slice(1),
+ subtitle: host + ':' + String(protocol.port || 0),
+ uri: '',
+ details: []
+ };
+ if (runtimeState?.bootstrap_status) {
+ card.details.push('Node Status: ' + runtimeState.bootstrap_status);
+ }
+ if (protocol.type === 'vless') {
+ card.uri = buildVlessHint(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'UUID: ' + String(protocol.auth?.uuid || ''),
+ 'TLS: ' + String(Boolean(protocol.tls?.enabled)),
+ 'SNI: ' + String(protocol.tls?.server_name || ''),
+ 'Path: ' + String(protocol.extra?.path || '/ws'),
+ );
+ } else if (protocol.type === 'vless-reality') {
+ card.uri = buildRealityLink(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'UUID: ' + String(protocol.auth?.uuid || ''),
+ 'SNI: ' + String(protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com'),
+ 'Fingerprint: ' + String(protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome'),
+ 'Public Key: ' + String(protocol.tls?.reality?.public_key || protocol.reality?.public_key || ''),
+ 'Short ID: ' + String(protocol.tls?.reality?.short_id || protocol.reality?.short_id || ''),
+ );
+ } else if (protocol.type === 'shadowsocks') {
+ card.uri = buildShadowsocksLink(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'Method: ' + String(protocol.auth?.method || ''),
+ 'Password: ' + String(protocol.auth?.password || ''),
+ );
+ } else if (protocol.type === 'vmess') {
+ card.uri = buildVmessLink(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'UUID: ' + String(protocol.auth?.uuid || ''),
+ 'TLS: ' + String(Boolean(protocol.tls?.enabled)),
+ 'Path: ' + String(protocol.extra?.path || '/vmess'),
+ );
+ } else if (protocol.type === 'hysteria2') {
+ const up = Number(protocol.extra?.up_mbps || 0);
+ const down = Number(protocol.extra?.down_mbps || 0);
+ card.uri = buildHysteria2Link(node, protocol);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ 'Password: ' + String(protocol.auth?.password || ''),
+ 'Obfs Password: ' + String(protocol.extra?.obfs_password || ''),
+ 'Bandwidth Hint: ' + (up > 0 || down > 0 ? ('up ' + (up || 'auto') + ' / down ' + (down || 'auto') + ' Mbps') : 'auto'),
+ );
+ } else if (protocol.type === 'socks5' || protocol.type === 'socks') {
+ card.uri = 'socks5://' + host + ':' + String(protocol.port || 0);
+ card.details.push(
+ 'Server: ' + host,
+ 'Port: ' + protocol.port,
+ );
+ }
+ return card;
+ });
+ }
+
+ function renderReadyCards(node, runtimeState) {
+ readyCardsEl.innerHTML = '';
+ const cards = protocolCards(node, runtimeState);
+ if (!cards.length) {
+ readyCardsEl.innerHTML = '<div class="muted-box">Выберите узел с включёнными протоколами, чтобы увидеть здесь готовые ссылки для копирования.</div>';
+ return;
+ }
+ cards.forEach(card => {
+ const detailText = card.details.join('\n');
+ const el = document.createElement('div');
+ el.className = 'ready-card';
+ el.innerHTML =
+ '<div class="ready-card-head">' +
+ '<div>' +
+ '<div class="ready-card-title">' + card.title + '</div>' +
+ '<div class="ready-card-sub">' + card.subtitle + '</div>' +
+ '</div>' +
+ '<div class="toolbar">' +
+ (card.uri ? '<button class="ghost" type="button" data-copy="' + encodeURIComponent(card.uri) + '">Копировать URI</button>' : '') +
+ '<button class="ghost" type="button" data-copy="' + encodeURIComponent(detailText) + '">Копировать детали</button>' +
+ '</div>' +
+ '</div>' +
+ (card.uri ? '<div class="mono">' + card.uri + '</div>' : '') +
+ '<div class="mono">' + detailText.replace(/\n/g, '<br>') + '</div>';
+ readyCardsEl.appendChild(el);
+ });
+ }
+
+ function buildVlessHint(node, protocol) {
+ const host = node.domain || node.host || '';
+ const path = String(protocol.extra?.path || '/ws');
+ const sni = String(protocol.tls?.server_name || node.domain || '');
+ return 'vless://' + String(protocol.auth?.uuid || '') + '@' + host + ':' + String(protocol.port || 0) + '?security=' + (protocol.tls?.enabled ? 'tls' : 'none') + '&type=ws&path=' + encodeURIComponent(path) + '&sni=' + encodeURIComponent(sni) + '#' + encodeURIComponent(node.id || 'vless');
+ }
+
+ function buildRealityLink(node, protocol) {
+ const host = node.domain || node.host || '';
+ const sni = String(protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com');
+ const fp = String(protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome');
+ const pbk = String(protocol.tls?.reality?.public_key || protocol.reality?.public_key || '');
+ const sid = String(protocol.tls?.reality?.short_id || protocol.reality?.short_id || '');
+ return 'vless://' + String(protocol.auth?.uuid || '') + '@' + host + ':' + String(protocol.port || 0) + '?encryption=none&security=reality&sni=' + encodeURIComponent(sni) + '&fp=' + encodeURIComponent(fp) + '&pbk=' + encodeURIComponent(pbk) + '&sid=' + encodeURIComponent(sid) + '&type=tcp#' + encodeURIComponent(node.id || 'vless-reality');
+ }
+
+ function protocolSummary(node, runtimeState) {
+ if (!node) return 'No node selected.';
+ const lines = [];
+ lines.push('Node: ' + (node.name || node.id || ''));
+ lines.push('Host: ' + (node.domain || node.host || ''));
+ if (runtimeState?.bootstrap_status) {
+ lines.push('Status: ' + runtimeState.bootstrap_status);
+ }
+ lines.push('');
+ for (const protocol of (node.protocols || []).filter(p => p.enabled)) {
+ if (protocol.type === 'vless') {
+ lines.push('VLESS');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' uuid: ' + (protocol.auth?.uuid || ''));
+ lines.push(' tls: ' + String(Boolean(protocol.tls?.enabled)));
+ lines.push(' server_name: ' + (protocol.tls?.server_name || ''));
+ lines.push(' path: ' + (protocol.extra?.path || ''));
+ lines.push(' uri: ' + buildVlessHint(node, protocol));
+ } else if (protocol.type === 'vless-reality') {
+ lines.push('VLESS REALITY');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' uuid: ' + (protocol.auth?.uuid || ''));
+ lines.push(' server_name: ' + (protocol.tls?.server_name || protocol.reality?.server_name || 'www.nokia.com'));
+ lines.push(' public_key: ' + (protocol.tls?.reality?.public_key || protocol.reality?.public_key || ''));
+ lines.push(' short_id: ' + (protocol.tls?.reality?.short_id || protocol.reality?.short_id || ''));
+ lines.push(' fingerprint: ' + (protocol.tls?.reality?.fingerprint || protocol.reality?.fingerprint || 'chrome'));
+ lines.push(' uri: ' + buildRealityLink(node, protocol));
+ } else if (protocol.type === 'shadowsocks') {
+ lines.push('Shadowsocks');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' method: ' + (protocol.auth?.method || ''));
+ lines.push(' password: ' + (protocol.auth?.password || ''));
+ lines.push(' uri: ' + buildShadowsocksLink(node, protocol));
+ } else if (protocol.type === 'socks5' || protocol.type === 'socks') {
+ lines.push('SOCKS5');
+ lines.push(' server: ' + (node.host || ''));
+ lines.push(' port: ' + protocol.port);
+ } else if (protocol.type === 'vmess') {
+ lines.push('VMess');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' uuid: ' + (protocol.auth?.uuid || ''));
+ lines.push(' tls: ' + String(Boolean(protocol.tls?.enabled)));
+ lines.push(' path: ' + (protocol.extra?.path || '/vmess'));
+ lines.push(' uri: ' + buildVmessLink(node, protocol));
+ } else if (protocol.type === 'hysteria2') {
+ lines.push('Hysteria2');
+ lines.push(' server: ' + (node.domain || node.host));
+ lines.push(' port: ' + protocol.port);
+ lines.push(' password: ' + (protocol.auth?.password || ''));
+ lines.push(' obfs_password: ' + (protocol.extra?.obfs_password || ''));
+ lines.push(' uri: ' + buildHysteria2Link(node, protocol));
+ }
+ lines.push('');
+ }
+ return lines.join('\n').trim();
+ }
+
+ async function api(path, options = {}) {
+ const headers = new Headers(options.headers || {});
+ const token = tokenInput.value.trim();
+ if (token) headers.set('X-Admin-Token', token);
+ if (options.body && !headers.has('Content-Type')) {
+ headers.set('Content-Type', 'application/json');
+ }
+ const res = await fetch(path, { ...options, headers });
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || res.statusText);
+ }
+ return res.json();
+ }
+
+ function currentActionPayload() {
+ const auth = String(form.ssh_auth.value || 'key');
+ if (auth === 'password') {
+ return { ssh_password: String(form.ssh_runtime_password.value || '').trim() };
+ }
+ return {};
+ }
+
+ function currentNodeFromForm() {
+ const fd = new FormData(form);
+ const protocols = [];
+ if (fd.get('vless_enabled') === 'true') {
+ protocols.push({
+ type: 'vless',
+ enabled: true,
+ port: Number(fd.get('vless_port')),
+ tls: {
+ enabled: fd.get('vless_tls_enabled') === 'true',
+ server_name: String(fd.get('vless_server_name') || '')
+ },
+ auth: {
+ uuid: String(fd.get('vless_uuid') || '')
+ },
+ extra: {
+ transport_type: String(fd.get('vless_transport_type') || ''),
+ path: String(fd.get('vless_path') || '')
+ }
+ });
+ }
+ if (fd.get('reality_enabled') === 'true') {
+ protocols.push({
+ type: 'vless-reality',
+ enabled: true,
+ port: Number(fd.get('reality_port')),
+ tls: {
+ enabled: true,
+ server_name: String(fd.get('reality_server_name') || '')
+ },
+ auth: {
+ uuid: String(fd.get('reality_uuid') || '')
+ },
+ reality: {
+ server_name: String(fd.get('reality_server_name') || ''),
+ server_port: Number(fd.get('reality_server_port') || 443),
+ public_key: String(fd.get('reality_public_key') || ''),
+ private_key: String(fd.get('reality_private_key') || ''),
+ short_id: String(fd.get('reality_short_id') || ''),
+ fingerprint: String(fd.get('reality_fingerprint') || '')
+ }
+ });
+ }
+ if (fd.get('ss_enabled') === 'true') {
+ protocols.push({
+ type: 'shadowsocks',
+ enabled: true,
+ port: Number(fd.get('ss_port')),
+ auth: {
+ method: String(fd.get('ss_method') || ''),
+ password: String(fd.get('ss_password') || '')
+ }
+ });
+ }
+ if (fd.get('socks_enabled') === 'true') {
+ protocols.push({
+ type: 'socks5',
+ enabled: true,
+ port: Number(fd.get('socks_port'))
+ });
+ }
+ if (fd.get('vmess_enabled') === 'true') {
+ protocols.push({
+ type: 'vmess',
+ enabled: true,
+ port: Number(fd.get('vmess_port')),
+ tls: {
+ enabled: fd.get('vmess_tls_enabled') === 'true',
+ server_name: String(fd.get('vmess_server_name') || '')
+ },
+ auth: {
+ uuid: String(fd.get('vmess_uuid') || '')
+ },
+ extra: {
+ path: String(fd.get('vmess_path') || '')
+ }
+ });
+ }
+ if (fd.get('hy2_enabled') === 'true') {
+ protocols.push({
+ type: 'hysteria2',
+ enabled: true,
+ port: Number(fd.get('hy2_port')),
+ auth: {
+ password: String(fd.get('hy2_password') || '')
+ },
+ extra: {
+ up_mbps: Number(fd.get('hy2_up_mbps') || 0),
+ down_mbps: Number(fd.get('hy2_down_mbps') || 0),
+ obfs_password: String(fd.get('hy2_obfs_password') || ''),
+ tls_cert_path: String(fd.get('hy2_tls_cert_path') || ''),
+ tls_key_path: String(fd.get('hy2_tls_key_path') || '')
+ }
+ });
+ }
+ return {
+ id: String(fd.get('id') || ''),
+ name: String(fd.get('name') || ''),
+ provider: String(fd.get('provider') || ''),
+ region: String(fd.get('region') || ''),
+ host: String(fd.get('host') || ''),
+ domain: String(fd.get('domain') || ''),
+ acme_email: String(fd.get('acme_email') || ''),
+ enabled: fd.get('enabled') === 'true',
+ ssh: {
+ user: String(fd.get('ssh_user') || ''),
+ port: Number(fd.get('ssh_port') || 22),
+ auth: String(fd.get('ssh_auth') || 'key'),
+ identity_file: String(fd.get('ssh_identity') || ''),
+ password_env: String(fd.get('ssh_password_env') || '')
+ },
+ protocols
+ };
+ }
+
+ function fillForm(node) {
+ state.selectedNodeID = node.id || '';
+ form.id.value = node.id || '';
+ form.name.value = node.name || '';
+ form.provider.value = node.provider || 'custom-vps';
+ form.region.value = node.region || '';
+ form.enabled.value = String(Boolean(node.enabled));
+ form.host.value = node.host || '';
+ form.domain.value = node.domain || '';
+ form.acme_email.value = node.acme_email || '';
+ form.ssh_user.value = node.ssh?.user || 'root';
+ form.ssh_port.value = node.ssh?.port || 22;
+ form.ssh_auth.value = node.ssh?.auth || 'key';
+ form.ssh_identity.value = node.ssh?.identity_file || '';
+ form.ssh_password_env.value = node.ssh?.password_env || '';
+ form.ssh_runtime_password.value = '';
+
+ const vless = (node.protocols || []).find(p => p.type === 'vless');
+ form.vless_enabled.value = String(Boolean(vless?.enabled));
+ form.vless_port.value = vless?.port || 443;
+ form.vless_uuid.value = vless?.auth?.uuid || '';
+ form.vless_tls_enabled.value = String(Boolean(vless?.tls?.enabled));
+ form.vless_server_name.value = vless?.tls?.server_name || '';
+ form.vless_transport_type.value = vless?.extra?.transport_type || '';
+ form.vless_path.value = vless?.extra?.path || '';
+
+ const reality = (node.protocols || []).find(p => p.type === 'vless-reality');
+ form.reality_enabled.value = String(Boolean(reality?.enabled));
+ form.reality_port.value = reality?.port || 443;
+ form.reality_uuid.value = reality?.auth?.uuid || '';
+ form.reality_server_name.value = reality?.reality?.server_name || reality?.tls?.server_name || 'www.nokia.com';
+ form.reality_server_port.value = reality?.reality?.server_port || 443;
+ form.reality_public_key.value = reality?.reality?.public_key || reality?.tls?.reality?.public_key || '';
+ form.reality_private_key.value = reality?.reality?.private_key || '';
+ form.reality_short_id.value = reality?.reality?.short_id || reality?.tls?.reality?.short_id || '';
+ form.reality_fingerprint.value = reality?.reality?.fingerprint || reality?.tls?.reality?.fingerprint || 'chrome';
+
+ const ss = (node.protocols || []).find(p => p.type === 'shadowsocks');
+ form.ss_enabled.value = String(Boolean(ss?.enabled));
+ form.ss_port.value = ss?.port || 8443;
+ form.ss_method.value = ss?.auth?.method || '';
+ form.ss_password.value = ss?.auth?.password || '';
+
+ const socks = (node.protocols || []).find(p => p.type === 'socks5' || p.type === 'socks');
+ form.socks_enabled.value = String(Boolean(socks?.enabled));
+ form.socks_port.value = socks?.port || 1080;
+
+ const vmess = (node.protocols || []).find(p => p.type === 'vmess');
+ form.vmess_enabled.value = String(Boolean(vmess?.enabled));
+ form.vmess_port.value = vmess?.port || 443;
+ form.vmess_uuid.value = vmess?.auth?.uuid || '';
+ form.vmess_tls_enabled.value = String(Boolean(vmess?.tls?.enabled));
+ form.vmess_server_name.value = vmess?.tls?.server_name || '';
+ form.vmess_path.value = vmess?.extra?.path || '';
+
+ const hy2 = (node.protocols || []).find(p => p.type === 'hysteria2');
+ form.hy2_enabled.value = String(Boolean(hy2?.enabled));
+ form.hy2_port.value = hy2?.port || 8443;
+ form.hy2_password.value = hy2?.auth?.password || '';
+ form.hy2_up_mbps.value = hy2?.extra?.up_mbps || '';
+ form.hy2_down_mbps.value = hy2?.extra?.down_mbps || '';
+ form.hy2_obfs_password.value = hy2?.extra?.obfs_password || '';
+ form.hy2_tls_cert_path.value = hy2?.extra?.tls_cert_path || '';
+ form.hy2_tls_key_path.value = hy2?.extra?.tls_key_path || '';
+ summaryViewEl.textContent = protocolSummary(node, state.states[node.id]);
+ renderReadyCards(node, state.states[node.id]);
+ renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(node, state.states[node.id], state.publishDecisions[node.id]));
+ const guide = selectedNodeGuide(node, state.states[node.id], state.publishDecisions[node.id]);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ updateCurrentSystem();
+ openAdvancedControls();
+ loadNodeState(node.id).catch(() => {});
+ }
+
+ function currentNodeID() {
+ return String(form.id.value || '').trim();
+ }
+
+ function normalizeHost(value) {
+ return String(value || '').trim().toLowerCase().replace(/\.$/, '');
+ }
+
+ function findExistingNodeByHost(host) {
+ const needle = normalizeHost(host);
+ if (!needle) return null;
+ return state.nodes.find(node => normalizeHost(node.host) === needle) || null;
+ }
+
+ function updateQuickHostStatus() {
+ const existing = findExistingNodeByHost(quickHostEl.value);
+ if (!existing) {
+ quickHostStatusEl.style.display = 'none';
+ quickHostStatusEl.textContent = '';
+ renderStatusRail(quickStatusRailEl, []);
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', [
+ 'Сначала нажмите «Проверить VPS», чтобы понять, подходит ли сервер для MULTI или SOCKS5.',
+ 'Затем нажимайте «Создать прокси» только если панель показывает, что VPS готов.',
+ ]);
+ return;
+ }
+ const protocols = (existing.protocols || []).filter(p => p.enabled).map(p => p.type).join(', ') || 'no enabled protocols';
+ quickHostStatusEl.style.display = 'block';
+ quickHostStatusEl.innerHTML = '<strong>Этот VPS уже под управлением.</strong><br>Узел <code>' + existing.id + '</code> уже использует этот хост со следующими протоколами: ' + protocols + '. Откройте его в настройках и используйте обновление или переустановку вместо создания второго quick-узла.';
+ renderStatusRail(quickStatusRailEl, quickStatusItems(null, existing));
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', [
+ 'Не создавайте второй quick-узел на этом VPS.',
+ 'Откройте существующий узел в настройках.',
+ protocols.includes('vless-reality') && protocols.includes('hysteria2') && !protocols.includes('socks5')
+ ? 'Если нужен fallback-прокси, используйте «Добавить SOCKS5».'
+ : 'Используйте обновление или нужный вариант переустановки в зависимости от проблемы.',
+ ], 'warn');
+ }
+
+ function renderQuickPreflight(data) {
+ if (!data) {
+ quickHostStatusEl.style.display = 'none';
+ renderStatusRail(quickStatusRailEl, []);
+ renderGuideBox(quickGuideEl, '', []);
+ return;
+ }
+ const warnings = Array.isArray(data.warnings) ? data.warnings : [];
+ const multi = data.quick_multi || {};
+ const socks = data.quick_socks5 || {};
+ const capabilities = Array.isArray(data.capabilities) ? data.capabilities : [];
+ const suggestedMulti = data.suggested_multi_name || '';
+ const suggestedSocks = data.suggested_socks_name || '';
+ const managed = data.already_managed ? '<strong>Этот VPS уже управляется vpnem.</strong><br>' : '';
+ const hostState = data.host_state_label ? '<strong>Статус VPS:</strong> ' + data.host_state_label + '<br>' : '';
+ const multiLine = 'MULTI: ' + (multi.supported ? 'готов' : 'заблокирован') + (multi.reasons && multi.reasons.length ? ' — ' + multi.reasons.join(' · ') : '');
+ const socksLine = 'SOCKS5: ' + (socks.supported ? 'готов' : 'заблокирован') + (socks.reasons && socks.reasons.length ? ' — ' + socks.reasons.join(' · ') : '');
+ const portLine = 'Порты — tcp/443: ' + (data.ports?.tcp_443 || 'unknown') + ', udp/443: ' + (data.ports?.udp_443 || 'unknown') + ', tcp/54101: ' + (data.ports?.tcp_54101 || 'unknown');
+ const capsLine = capabilities.length ? 'Возможности — ' + capabilities.join(' · ') : '';
+ const nameLine = suggestedMulti || suggestedSocks ? 'Автоимена — MULTI: ' + (suggestedMulti || 'auto') + (suggestedSocks ? ' · SOCKS5: ' + suggestedSocks : '') : '';
+ const warningLine = warnings.length ? '<br><span style="color:#b45309">' + warnings.join(' ') + '</span>' : '';
+ quickHostStatusEl.style.display = 'block';
+ quickHostStatusEl.innerHTML =
+ managed +
+ hostState +
+ '<strong>' + (data.os_pretty || data.os_id || 'Неизвестный Linux') + '</strong> · уровень поддержки: <strong>' + (data.support_tier || 'unknown') + '</strong><br>' +
+ portLine + '<br>' +
+ (capsLine ? capsLine + '<br>' : '') +
+ (nameLine ? nameLine + '<br>' : '') +
+ multiLine + '<br>' +
+ socksLine +
+ (data.recommended_action ? '<br><strong>Следующий шаг:</strong> ' + data.recommended_action : '') +
+ warningLine;
+ renderStatusRail(quickStatusRailEl, quickStatusItems(data, null));
+ const guideLines = [];
+ if (data.already_managed) {
+ guideLines.push('Этот VPS уже используется одной из нод vpnem.');
+ guideLines.push('Откройте существующий узел в настройках вместо создания нового quick-узла.');
+ if (socks.supported && !multi.supported) {
+ guideLines.push('Если нужен только простой fallback-прокси, здесь безопаснее добавить SOCKS5.');
+ }
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'warn');
+ return;
+ }
+ if (multi.supported) {
+ guideLines.push('Этот VPS выглядит безопасным для стандартной установки MULTI.');
+ guideLines.push('Нажмите «Создать прокси», если хотите TCP через REALITY и UDP через Hysteria2.');
+ if (suggestedMulti) {
+ guideLines.push('Имя узла будет создано автоматически, например: ' + suggestedMulti + '.');
+ }
+ if (socks.supported) {
+ guideLines.push('Выбирайте SOCKS5 только если хотите более простой прокси без multi-транспортной схемы.');
+ }
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines);
+ return;
+ }
+ if (socks.supported) {
+ guideLines.push('Сейчас MULTI на этом VPS заблокирован.');
+ guideLines.push('Здесь безопасным quick-вариантом является SOCKS5.');
+ if (suggestedSocks) {
+ guideLines.push('Имя SOCKS5-узла будет создано автоматически, например: ' + suggestedSocks + '.');
+ }
+ guideLines.push('Если позже понадобится MULTI, сначала освободите tcp/443 и udp/443.');
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'warn');
+ return;
+ }
+ guideLines.push('Быстрая установка на этом VPS сейчас заблокирована.');
+ guideLines.push(data.recommended_action || 'Сначала исправьте найденные конфликты, затем снова выполните проверку.');
+ renderGuideBox(quickGuideEl, 'Что можно сделать здесь', guideLines, 'danger');
+ }
+
+ async function loadNodeState(nodeID) {
+ if (!nodeID) {
+ stateViewEl.textContent = '';
+ return;
+ }
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/state');
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ const node = state.nodes.find(item => item.id === nodeID);
+ if (node) {
+ state.states[nodeID] = data;
+ summaryViewEl.textContent = protocolSummary(node, data);
+ renderReadyCards(node, data);
+ renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(node, data, state.publishDecisions[node.id]));
+ const guide = selectedNodeGuide(node, data, state.publishDecisions[node.id]);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ }
+ } catch (error) {
+ stateViewEl.textContent = 'Сохранённого состояния пока нет.';
+ }
+ }
+
+ function renderNodes() {
+ nodeListEl.innerHTML = '';
+ renderFleetFilters();
+ if (!state.nodes.length) {
+ nodeListEl.innerHTML = '<div class="muted-box empty-box">Пока нет ни одного узла. Используйте <strong>Быструю установку</strong>, чтобы превратить VPS с IP и паролем в опубликованный узел.</div>';
+ updateCurrentSystem();
+ return;
+ }
+
+ const visibleNodes = state.nodes.filter(node => nodeMatchesFleetFilter(node));
+ if (!visibleNodes.length) {
+ nodeListEl.innerHTML = '<div class="muted-box empty-box">Сейчас ни один узел не подходит под этот фильтр.</div>';
+ updateCurrentSystem();
+ return;
+ }
+
+ visibleNodes.forEach(node => {
+ const el = document.createElement('button');
+ el.type = 'button';
+ el.className = 'node-card';
+ const protocols = (node.protocols || []).filter(p => p.enabled).map(p => p.type).join(', ') || 'no enabled protocols';
+ const nodeState = state.states[node.id];
+ const decision = state.publishDecisions[node.id];
+ const publicHost = nodeState?.public_host || node.domain || node.host;
+ const publishReady = decision ? Boolean(decision.eligible) : false;
+ const publishLabel = publishReady ? 'Готов к публикации' : 'Нужно внимание';
+ const publishClass = publishReady ? 'ready' : 'blocked';
+ const lifecycle = nodeLifecycleLabel(node, nodeState, decision);
+ const product = nodeProductState(node, nodeState, decision);
+ const protocolBadges = (node.protocols || []).filter(p => p.enabled).map(p => '<span class="badge protocol">' + p.type + '</span>').join('');
+ const reasons = decision && decision.reasons && decision.reasons.length
+ ? '<div class="node-meta" style="color:#b45309">Почему нужен шаг: ' + decision.reasons.join(' · ') + '</div>'
+ : '';
+ el.innerHTML =
+ '<div class="node-header">' +
+ '<div>' +
+ '<div class="node-title">' + product.title + '</div>' +
+ '<div class="node-meta" style="margin-top:4px">' + (node.name || node.id) + '</div>' +
+ '<div class="node-meta">' + [node.region, publicHost].filter(Boolean).join(' · ') + '</div>' +
+ '</div>' +
+ '<div class="badges">' +
+ '<span class="badge ' + publishClass + '">' + publishLabel + '</span>' +
+ '<span class="badge ' + lifecycle.tone + '">' + lifecycle.label + '</span>' +
+ '</div>' +
+ '</div>' +
+ '<div class="muted-box" style="padding:12px">' +
+ '<strong>' + product.subtitle + '</strong><br>' +
+ product.nextStep +
+ '</div>' +
+ '<div class="badges">' + protocolBadges + '</div>' +
+ reasons;
+ if (state.selectedNodeID && state.selectedNodeID === node.id) {
+ el.style.borderColor = 'var(--accent)';
+ el.style.boxShadow = '0 0 0 2px rgba(15, 118, 110, 0.14)';
+ }
+ el.addEventListener('click', () => fillForm(node));
+ nodeListEl.appendChild(el);
+ });
+ updateCurrentSystem();
+ }
+
+ async function loadNodes() {
+ setStatus('Загрузка узлов...', 'info');
+ const data = await api('/api/v1/control/nodes');
+ state.nodes = data.nodes || [];
+ state.states = data.states || {};
+ state.publishDecisions = data.publish_decisions || {};
+ renderNodes();
+ updateQuickHostStatus();
+ if (state.selectedNodeID) {
+ const selectedNode = state.nodes.find(node => node.id === state.selectedNodeID);
+ if (selectedNode) {
+ summaryViewEl.textContent = protocolSummary(selectedNode, state.states[selectedNode.id]);
+ renderReadyCards(selectedNode, state.states[selectedNode.id]);
+ renderStatusRail(nodeStatusRailEl, selectedNodeStatusItems(selectedNode, state.states[selectedNode.id], state.publishDecisions[selectedNode.id]));
+ const guide = selectedNodeGuide(selectedNode, state.states[selectedNode.id], state.publishDecisions[selectedNode.id]);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ }
+ } else {
+ renderStatusRail(nodeStatusRailEl, []);
+ const guide = selectedNodeGuide(null, null, null);
+ renderGuideBox(nodeGuideEl, guide.title, guide.lines, guide.tone);
+ }
+ setStatus('Узлы загружены.', 'success');
+ }
+
+ form.addEventListener('submit', async (event) => {
+ event.preventDefault();
+ setStatus('Сохранение узла...', 'info');
+ try {
+ const payload = currentNodeFromForm();
+ await api('/api/v1/control/nodes', {
+ method: 'POST',
+ body: JSON.stringify(payload)
+ });
+ state.selectedNodeID = payload.id;
+ await loadNodes();
+ await loadNodeState(payload.id);
+ setStatus('Узел сохранён.', 'success');
+ } catch (error) {
+ setStatus('Не удалось сохранить узел: ' + error.message, 'error');
+ }
+ });
+
+ async function runNodeAction(action, dryRun = false) {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus(action + '...', 'info');
+ try {
+ const suffix = dryRun ? '?dry_run=true' : '';
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action + suffix, {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ await loadNodes();
+ await loadNodeState(nodeID);
+ setStatus('Действие завершено.', 'success');
+ } catch (error) {
+ setStatus('Ошибка действия: ' + error.message, 'error');
+ }
+ }
+
+ async function runSimpleNodeAction(action, successMessage) {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus(action + '...', 'info');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action, {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ if (data.node) {
+ fillForm(data.node);
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ await loadNodes();
+ if (data.node?.id) {
+ await loadNodeState(data.node.id);
+ } else if (nodeID) {
+ await loadNodeState(nodeID).catch(() => {});
+ }
+ setStatus(successMessage);
+ } catch (error) {
+ setStatus('Ошибка действия: ' + error.message);
+ }
+ }
+
+ async function runDNSAction(action) {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus(action + '...');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/' + action, {
+ method: 'POST'
+ });
+ if (data.node) {
+ fillForm(data.node);
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ setStatus('Действие завершено.', 'success');
+ await loadNodes();
+ } catch (error) {
+ setStatus('Ошибка действия: ' + error.message, 'error');
+ }
+ }
+
+ async function runProvisionNode() {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.');
+ return;
+ }
+ setStatus('Подготовка узла...', 'info');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/provision', {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ if (data.dns?.fqdn) {
+ form.domain.value = data.dns.fqdn;
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ state.selectedNodeID = nodeID;
+ setStatus(data.ready_for_catalog ? 'Подготовка завершена, каталог опубликован.' : 'Подготовка завершена, но публикация была пропущена.', data.ready_for_catalog ? 'success' : 'error');
+ await loadNodes();
+ await loadNodeState(nodeID);
+ } catch (error) {
+ setStatus('Подготовка завершилась ошибкой: ' + error.message, 'error');
+ }
+ }
+
+ async function runQuickProvision() {
+ const host = String(quickHostEl.value || '').trim();
+ const rootPassword = String(quickRootPasswordEl.value || '').trim();
+ if (!host || !rootPassword) {
+ setStatus('Нужны host и root-пароль.');
+ return;
+ }
+ const existing = findExistingNodeByHost(host);
+ if (existing) {
+ fillForm(existing);
+ openAdvancedControls();
+ setStatus('Этот VPS уже управляется узлом ' + existing.id + '. Используйте настройки и действия вместо создания второго quick-узла.', 'error');
+ return;
+ }
+ setStatus('Быстрое создание...', 'info');
+ try {
+ const data = await api('/api/v1/control/quick-provision', {
+ method: 'POST',
+ body: JSON.stringify({
+ host,
+ root_password: rootPassword,
+ region: String(quickRegionEl.value || '').trim(),
+ provider: String(quickProviderEl.value || '').trim(),
+ acme_email: String(quickACMEEmailEl.value || '').trim(),
+ enable_multi: quickEnableMultiEl.checked,
+ enable_socks5: quickEnableSocksEl.checked,
+ })
+ });
+ if (data.node) {
+ fillForm(data.node);
+ state.selectedNodeID = data.node.id || '';
+ form.ssh_auth.value = 'password';
+ form.ssh_password_env.value = '';
+ form.ssh_runtime_password.value = rootPassword;
+ }
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ setStatus(data.ready_for_catalog ? 'Быстрое создание завершено, каталог опубликован.' : 'Быстрое создание завершено, но публикация была пропущена.', data.ready_for_catalog ? 'success' : 'error');
+ quickRootPasswordEl.value = '';
+ await loadNodes();
+ if (data.node?.id) {
+ await loadNodeState(data.node.id);
+ }
+ } catch (error) {
+ setStatus('Быстрое создание завершилось ошибкой: ' + error.message, 'error');
+ }
+ }
+
+ async function runQuickInspect() {
+ const host = String(quickHostEl.value || '').trim();
+ const rootPassword = String(quickRootPasswordEl.value || '').trim();
+ if (!host || !rootPassword) {
+ setStatus('Для проверки VPS нужны host и root-пароль.', 'error');
+ return;
+ }
+ setStatus('Проверка VPS...', 'info');
+ try {
+ const data = await api('/api/v1/control/preflight', {
+ method: 'POST',
+ body: JSON.stringify({
+ host,
+ root_password: rootPassword,
+ region: String(quickRegionEl.value || '').trim(),
+ provider: String(quickProviderEl.value || '').trim()
+ })
+ });
+ renderQuickPreflight(data);
+ setStatus('Проверка VPS завершена.', 'success');
+ } catch (error) {
+ setStatus('Проверка VPS завершилась ошибкой: ' + error.message, 'error');
+ }
+ }
+
+ document.getElementById('publishBtn').addEventListener('click', async () => {
+ setStatus('Публикация каталога...', 'info');
+ try {
+ const data = await api('/api/v1/control/catalog/publish', { method: 'POST' });
+ state.publishDecisions = data.publish_decisions || state.publishDecisions;
+ renderNodes();
+ setStatus('Каталог опубликован в data/servers.json (' + (data.count || 0) + ' узлов).', 'success');
+ } catch (error) {
+ setStatus('Публикация завершилась ошибкой: ' + error.message, 'error');
+ }
+ });
+
+ document.getElementById('refreshBtn').addEventListener('click', loadNodes);
+ jumpInstallBtn.addEventListener('click', () => scrollToSection(quickStartEl));
+ jumpAdvancedBtn.addEventListener('click', () => openAdvancedControls());
+ document.getElementById('copySummaryBtn').addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(summaryViewEl.textContent || '');
+ setStatus('Сводка скопирована.', 'success');
+ } catch (error) {
+ setStatus('Ошибка копирования: ' + error.message, 'error');
+ }
+ });
+ readyCardsEl.addEventListener('click', async (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const copy = target.getAttribute('data-copy');
+ if (!copy) return;
+ try {
+ await navigator.clipboard.writeText(decodeURIComponent(copy));
+ setStatus('Скопировано в буфер обмена.', 'success');
+ } catch (error) {
+ setStatus('Ошибка копирования: ' + error.message, 'error');
+ }
+ });
+ quickInspectBtn.addEventListener('click', runQuickInspect);
+ quickPresetGridEl.addEventListener('click', (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const button = target.closest('[data-preset]');
+ if (!(button instanceof HTMLElement)) return;
+ setQuickPreset(button.getAttribute('data-preset') || 'multi');
+ });
+ fleetFiltersEl.addEventListener('click', (event) => {
+ const target = event.target;
+ if (!(target instanceof HTMLElement)) return;
+ const filter = target.getAttribute('data-fleet-filter');
+ if (!filter) return;
+ state.fleetFilter = filter;
+ renderNodes();
+ });
+ document.getElementById('quickProvisionBtn').addEventListener('click', runQuickProvision);
+ quickHostEl.addEventListener('input', updateQuickHostStatus);
+ document.getElementById('resetBtn').addEventListener('click', () => form.reset());
+ document.getElementById('enableNodeBtn').addEventListener('click', () => runSimpleNodeAction('enable', 'Узел включён, каталог перепубликован.'));
+ document.getElementById('disableNodeBtn').addEventListener('click', () => runSimpleNodeAction('disable', 'Узел выключен, каталог перепубликован.'));
+ document.getElementById('rotateSecretsBtn').addEventListener('click', () => runSimpleNodeAction('rotate-secrets', 'Секреты изменены. Выполните bootstrap ещё раз, чтобы применить runtime-изменения.'));
+ document.getElementById('provisionDnsBtn').addEventListener('click', () => runDNSAction('provision-dns'));
+ document.getElementById('provisionNodeBtn').addEventListener('click', runProvisionNode);
+ document.getElementById('deleteDnsBtn').addEventListener('click', () => runDNSAction('delete-dns'));
+ document.getElementById('bootstrapDryRunBtn').addEventListener('click', () => runNodeAction('bootstrap', true));
+ document.getElementById('bootstrapBtn').addEventListener('click', () => runNodeAction('bootstrap', false));
+ document.getElementById('upgradeBtn').addEventListener('click', () => runNodeAction('upgrade', false));
+ document.getElementById('addSocks5Btn').addEventListener('click', async () => {
+ if (!confirm('Добавить SOCKS5 на порт 54101 к этому узлу и сразу обновить runtime?')) {
+ return;
+ }
+ await runNodeAction('add-socks5', false);
+ });
+ document.getElementById('repairReinstallBtn').addEventListener('click', async () => {
+ if (!confirm('Починить сервер: заново развернуть текущий runtime, сохранив настройки и секреты. Продолжить?')) {
+ return;
+ }
+ await runNodeAction('repair-reinstall', false);
+ });
+ document.getElementById('cleanReinstallBtn').addEventListener('click', async () => {
+ if (!confirm('Переустановить сервер с нуля: сменить секреты, очистить удалённый runtime, заново развернуть сервер и перепубликовать каталог. Продолжить?')) {
+ return;
+ }
+ await runNodeAction('clean-reinstall', false);
+ });
+ document.getElementById('checkBtn').addEventListener('click', () => runNodeAction('check', false));
+ document.getElementById('destroyNodeBtn').addEventListener('click', async () => {
+ const nodeID = currentNodeID();
+ if (!nodeID) {
+ setStatus('Нужен ID узла.', 'error');
+ return;
+ }
+ if (!confirm('Удалить сервер ' + nodeID + ', убрать удалённый runtime, по возможности удалить DNS и очистить inventory/state?')) {
+ return;
+ }
+ setStatus('Удаление сервера...', 'info');
+ try {
+ const data = await api('/api/v1/control/nodes/' + encodeURIComponent(nodeID) + '/destroy', {
+ method: 'POST',
+ body: JSON.stringify(currentActionPayload())
+ });
+ stateViewEl.textContent = JSON.stringify(data, null, 2);
+ form.reset();
+ state.selectedNodeID = '';
+ summaryViewEl.textContent = '';
+ renderReadyCards(null, null);
+ await loadNodes();
+ setStatus(data.warnings && data.warnings.length ? 'Удаление завершено с предупреждениями.' : 'Сервер удалён.', data.warnings && data.warnings.length ? 'error' : 'success');
+ } catch (error) {
+ setStatus('Удаление завершилось ошибкой: ' + error.message, 'error');
+ }
+ });
+
+ renderReadyCards(null, null);
+ setQuickPreset('multi');
+ loadNodes().catch(error => {
+ setStatus('Начальная загрузка завершилась ошибкой: ' + error.message, 'error');
+ });
+ </script>
+</body>
+</html>`
+
+func dnsPrefixForNode(node control.Node) string {
+ prefix := strings.TrimSpace(node.Region)
+ if prefix == "" {
+ prefix = "vpn"
+ }
+ prefix = strings.ToLower(prefix)
+ prefix = strings.ReplaceAll(prefix, " ", "-")
+ return prefix
+}
+
+func normalizeHost(value string) string {
+ return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(value)), ".")
+}
+
+func canPublishNodeState(state control.NodeState) bool {
+ return control.NodeStateReadyForPublish(state)
+}