package api import ( "context" "encoding/json" "errors" "log" "net/http" "os" "path/filepath" "strings" "time" "vpnem/internal/control" ) func (h *Handler) ControlNodes(w http.ResponseWriter, r *http.Request) { if !h.authorizeAdmin(w, r) { return } inventory, err := control.LoadInventoryDir(h.inventoryDir()) if err != nil { if control.IsNotExist(err) { writeJSON(w, map[string]any{"nodes": []control.Node{}}) return } log.Printf("error loading control inventory: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } states := make(map[string]*control.NodeState, len(inventory.Nodes)) for _, node := range inventory.Nodes { state, err := control.LoadNodeState(h.stateDir(), node.ID) if err != nil { if control.IsNotExist(err) { continue } log.Printf("error loading node state for %s: %v", node.ID, err) continue } states[node.ID] = state } decisions := control.PublishDecisions(inventory.Nodes, states) writeJSON(w, map[string]any{ "nodes": inventory.Nodes, "states": states, "publish_decisions": decisions, }) } type actionRequest struct { SSHPassword string `json:"ssh_password"` } type quickProvisionRequest struct { Host string `json:"host"` RootPassword string `json:"root_password"` Region string `json:"region"` Provider string `json:"provider"` ACMEEmail string `json:"acme_email"` EnableMulti bool `json:"enable_multi"` EnableVLESS bool `json:"enable_vless"` EnableReality bool `json:"enable_vless_reality"` EnableSS bool `json:"enable_shadowsocks"` EnableSocks bool `json:"enable_socks5"` EnableVMess bool `json:"enable_vmess"` EnableHY2 bool `json:"enable_hysteria2"` } type quickPreflightDecision struct { Supported bool `json:"supported"` Reasons []string `json:"reasons,omitempty"` } type quickPreflightResponse struct { Host string `json:"host"` HostStateLabel string `json:"host_state_label,omitempty"` SuggestedMultiName string `json:"suggested_multi_name,omitempty"` SuggestedSocksName string `json:"suggested_socks_name,omitempty"` OSID string `json:"os_id,omitempty"` OSPretty string `json:"os_pretty,omitempty"` OSLike string `json:"os_like,omitempty"` SupportTier string `json:"support_tier"` AlreadyManaged bool `json:"already_managed"` DockerInstalled bool `json:"docker_installed"` ComposeAvailable bool `json:"compose_available"` Ports map[string]string `json:"ports"` Capabilities []string `json:"capabilities,omitempty"` RecommendedAction string `json:"recommended_action,omitempty"` QuickMulti quickPreflightDecision `json:"quick_multi"` QuickSocks5 quickPreflightDecision `json:"quick_socks5"` Warnings []string `json:"warnings,omitempty"` } func (h *Handler) ControlNodeByID(w http.ResponseWriter, r *http.Request) { if !h.authorizeAdmin(w, r) { return } nodeID := strings.TrimPrefix(r.URL.Path, "/api/v1/control/nodes/") nodeID = strings.TrimSuffix(nodeID, "/") if nodeID == "" { http.Error(w, "node id required", http.StatusBadRequest) return } node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } writeJSON(w, node) } func (h *Handler) ControlNodeAction(w http.ResponseWriter, r *http.Request) { if !h.authorizeAdmin(w, r) { return } path := strings.TrimPrefix(r.URL.Path, "/api/v1/control/nodes/") path = strings.TrimSuffix(path, "/") if path == "" { http.Error(w, "node id required", http.StatusBadRequest) return } switch { case strings.HasSuffix(path, "/state") && r.Method == http.MethodGet: nodeID := strings.TrimSuffix(path, "/state") h.ControlNodeState(w, r, nodeID) case strings.HasSuffix(path, "/provision") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/provision") h.ControlNodeProvision(w, r, nodeID) case strings.HasSuffix(path, "/provision-dns") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/provision-dns") h.ControlNodeProvisionDNS(w, r, nodeID) case strings.HasSuffix(path, "/delete-dns") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/delete-dns") h.ControlNodeDeleteDNS(w, r, nodeID) case strings.HasSuffix(path, "/bootstrap") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/bootstrap") h.ControlNodeBootstrap(w, r, nodeID) case strings.HasSuffix(path, "/check") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/check") h.ControlNodeCheck(w, r, nodeID) case strings.HasSuffix(path, "/upgrade") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/upgrade") h.ControlNodeUpgrade(w, r, nodeID) case strings.HasSuffix(path, "/add-socks5") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/add-socks5") h.ControlNodeAddSocks5(w, r, nodeID) case strings.HasSuffix(path, "/repair-reinstall") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/repair-reinstall") h.ControlNodeRepairReinstall(w, r, nodeID) case strings.HasSuffix(path, "/clean-reinstall") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/clean-reinstall") h.ControlNodeCleanReinstall(w, r, nodeID) case strings.HasSuffix(path, "/enable") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/enable") h.ControlNodeEnable(w, r, nodeID) case strings.HasSuffix(path, "/disable") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/disable") h.ControlNodeDisable(w, r, nodeID) case strings.HasSuffix(path, "/rotate-secrets") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/rotate-secrets") h.ControlNodeRotateSecrets(w, r, nodeID) case strings.HasSuffix(path, "/destroy") && r.Method == http.MethodPost: nodeID := strings.TrimSuffix(path, "/destroy") h.ControlNodeDestroy(w, r, nodeID) case r.Method == http.MethodDelete: h.DeleteControlNode(w, r, path) case r.Method == http.MethodGet: h.ControlNodeByID(w, r) default: http.Error(w, "method not allowed", http.StatusMethodNotAllowed) } } func (h *Handler) UpsertControlNode(w http.ResponseWriter, r *http.Request) { if !h.authorizeAdmin(w, r) { return } var node control.Node if err := json.NewDecoder(r.Body).Decode(&node); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } if node.SSH.Port == 0 { node.SSH.Port = 22 } if err := validateNodeForUI(node); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } path, err := control.SaveNodeFile(h.inventoryDir(), node) if err != nil { log.Printf("error saving control node: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } savedNode, err := control.LoadNodeFile(path) if err != nil { log.Printf("error reloading saved control node: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "saved": true, "path": path, "node": savedNode, }) } func (h *Handler) QuickProvisionControlNode(w http.ResponseWriter, r *http.Request) { if !h.authorizeAdmin(w, r) { return } var req quickProvisionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node, sshPassword, err := buildQuickProvisionNode(req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if existing, err := h.findNodeByHost(node.Host); err != nil { log.Printf("error checking quick-provision host conflicts: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } else if existing != nil { http.Error(w, "этот host уже используется узлом "+existing.ID+"; откройте его в настройках и используйте обновление или переустановку вместо создания второго quick-узла", http.StatusConflict) return } result, err := h.provisionNodeFlow(r.Context(), &node, sshPassword) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } result["node"] = node writeJSON(w, result) } func (h *Handler) QuickPreflightControlNode(w http.ResponseWriter, r *http.Request) { if !h.authorizeAdmin(w, r) { return } var req quickProvisionRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } host := strings.TrimSpace(req.Host) password := strings.TrimSpace(req.RootPassword) if host == "" || password == "" { http.Error(w, "host and root_password are required", http.StatusBadRequest) return } node := control.Node{ ID: "quick-preflight", Name: "Quick Preflight", Provider: strings.TrimSpace(req.Provider), Region: strings.TrimSpace(req.Region), Host: host, Enabled: true, SSH: control.SSHConfig{ User: "root", Port: 22, Auth: "password", Password: password, }, } result, err := control.SSHRunner{}.Run(r.Context(), node, control.RenderPreflightInspectScript()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } data := control.ParsePreflightInspectOutput(result.Stdout) resp := buildQuickPreflightResponse(host, data) writeJSON(w, resp) } func (h *Handler) DeleteControlNode(w http.ResponseWriter, r *http.Request, nodeID string) { if nodeID == "" { http.Error(w, "node id required", http.StatusBadRequest) return } if err := control.DeleteNodeFile(h.inventoryDir(), nodeID); err != nil { log.Printf("error deleting control node: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if err := control.DeleteNodeState(h.stateDir(), nodeID); err != nil { log.Printf("error deleting node state: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "deleted": true, "node_id": nodeID, }) } func (h *Handler) PublishControlCatalog(w http.ResponseWriter, r *http.Request) { if !h.authorizeAdmin(w, r) { return } count, target, catalogTarget, decisions, err := h.publishCurrentCatalog() if err != nil { log.Printf("error publishing control catalog: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "published": true, "target": target, "catalog_v2_target": catalogTarget, "count": count, "publish_decisions": decisions, }) } func (h *Handler) publishCurrentCatalog() (int, string, string, map[string]control.PublishDecision, error) { inventory, err := control.LoadInventoryDir(h.inventoryDir()) if err != nil { return 0, "", "", nil, err } states := make(map[string]*control.NodeState, len(inventory.Nodes)) for _, node := range inventory.Nodes { state, err := control.LoadNodeState(h.stateDir(), node.ID) if err != nil { if control.IsNotExist(err) { continue } log.Printf("error loading node state for publish %s: %v", node.ID, err) continue } states[node.ID] = state } publishable := control.PublishableNodes(inventory.Nodes, states) decisions := control.PublishDecisions(inventory.Nodes, states) target := filepath.Join(h.store.DataDir(), "servers.json") if err := control.WriteLegacyCatalog(target, publishable); err != nil { return 0, "", "", nil, err } catalogTarget := filepath.Join(h.store.DataDir(), "catalog-v2.json") if err := control.WriteCatalogV2(catalogTarget, publishable, states); err != nil { return 0, "", "", nil, err } return len(publishable), target, catalogTarget, decisions, nil } func (h *Handler) ControlNodeState(w http.ResponseWriter, r *http.Request, nodeID string) { state, err := control.LoadNodeState(h.stateDir(), nodeID) if err != nil { if control.IsNotExist(err) { http.NotFound(w, r) return } log.Printf("error loading node state: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } writeJSON(w, state) } func (h *Handler) ControlNodeBootstrap(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for bootstrap: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) dryRun := r.URL.Query().Get("dry_run") != "false" state, err := control.BootstrapNode(context.Background(), control.SSHRunner{}, *node, control.BootstrapOptions{ StateDir: h.stateDir(), DryRun: dryRun, }) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } writeJSON(w, state) } func (h *Handler) ControlNodeCheck(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for check: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) state, err := control.CheckNode(context.Background(), control.SSHRunner{}, *node, h.stateDir()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } writeJSON(w, state) } func (h *Handler) ControlNodeUpgrade(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for upgrade: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) state, err := control.UpgradeNode(context.Background(), control.SSHRunner{}, *node, h.stateDir()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } writeJSON(w, state) } func (h *Handler) ControlNodeAddSocks5(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for add socks5: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) inspect, err := control.SSHRunner{}.Run(r.Context(), *node, control.RenderPreflightInspectScript()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } preflight := control.ParsePreflightInspectOutput(inspect.Stdout) if portStatusValue(preflight["TCP_54101"]) == "busy" { http.Error(w, "tcp/54101 уже занят на этом VPS; безопасно добавить SOCKS5 нельзя", http.StatusConflict) return } updated, err := control.AddSocks5Protocol(*node, 54101) if err != nil { http.Error(w, err.Error(), http.StatusConflict) return } if _, err := control.SaveNodeFile(h.inventoryDir(), updated); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } updated.SSH.Password = node.SSH.Password state, err := control.UpgradeNode(context.Background(), control.SSHRunner{}, updated, h.stateDir()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } response := map[string]any{ "added": true, "protocol": "socks5", "node": updated, "state": state, "published": false, "recommended_next": "SOCKS5 was added and the node runtime was upgraded.", } if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil { response["published"] = true response["target"] = target response["catalog_v2_target"] = catalogTarget response["count"] = count response["publish_decisions"] = decisions } writeJSON(w, response) } func (h *Handler) ControlNodeRepairReinstall(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for repair reinstall: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) state, err := control.RepairReinstallNode(context.Background(), control.SSHRunner{}, *node, h.stateDir()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } response := map[string]any{ "reinstalled": true, "reinstall_mode": "repair", "node": node, "state": state, "published": false, "publish_decisions": map[string]control.PublishDecision{}, } if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil { response["published"] = true response["target"] = target response["catalog_v2_target"] = catalogTarget response["count"] = count response["publish_decisions"] = decisions } writeJSON(w, response) } func (h *Handler) ControlNodeCleanReinstall(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for clean reinstall: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) rotated, err := control.RotateNodeSecrets(*node) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if _, err := control.SaveNodeFile(h.inventoryDir(), rotated); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } rotated.SSH.Password = node.SSH.Password state, err := control.CleanReinstallNode(context.Background(), control.SSHRunner{}, rotated, h.stateDir()) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } response := map[string]any{ "reinstalled": true, "reinstall_mode": "clean", "rotated": true, "node": rotated, "state": state, "published": false, } if count, target, catalogTarget, decisions, pubErr := h.publishCurrentCatalog(); pubErr == nil { response["published"] = true response["target"] = target response["catalog_v2_target"] = catalogTarget response["count"] = count response["publish_decisions"] = decisions } writeJSON(w, response) } func (h *Handler) ControlNodeEnable(w http.ResponseWriter, r *http.Request, nodeID string) { h.updateNodeEnabled(w, nodeID, true) } func (h *Handler) ControlNodeDisable(w http.ResponseWriter, r *http.Request, nodeID string) { h.updateNodeEnabled(w, nodeID, false) } func (h *Handler) updateNodeEnabled(w http.ResponseWriter, nodeID string, enabled bool) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for enable toggle: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, nil) return } updated := control.SetNodeEnabled(*node, enabled) if _, err := control.SaveNodeFile(h.inventoryDir(), updated); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } count, target, catalogTarget, decisions, err := h.publishCurrentCatalog() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "saved": true, "enabled": enabled, "node": updated, "published": true, "target": target, "catalog_v2_target": catalogTarget, "count": count, "publish_decisions": decisions, }) } func (h *Handler) ControlNodeRotateSecrets(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for secret rotation: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } rotated, err := control.RotateNodeSecrets(*node) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } if _, err := control.SaveNodeFile(h.inventoryDir(), rotated); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "rotated": true, "node": rotated, }) } func (h *Handler) ControlNodeDestroy(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for destroy: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) var dnsClient control.DNSProvider if strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")) != "" && strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")) != "" { dnsClient = control.PorkbunClient{ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), } } warnings := control.DestroyNode(r.Context(), control.SSHRunner{}, dnsClient, "em-sysadmin.xyz", *node, h.inventoryDir(), h.stateDir()) count, target, catalogTarget, decisions, err := h.publishCurrentCatalog() if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "destroyed": true, "node_id": nodeID, "warnings": warnings, "published": true, "target": target, "catalog_v2_target": catalogTarget, "count": count, "publish_decisions": decisions, }) } func (h *Handler) ControlNodeProvision(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for full provision: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } req, err := decodeActionRequest(r) if err != nil { http.Error(w, "invalid json", http.StatusBadRequest) return } node = applyActionPassword(node, req) response, err := h.provisionNodeFlow(r.Context(), node, req.SSHPassword) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } writeJSON(w, response) } func (h *Handler) ControlNodeProvisionDNS(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for dns: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } client := control.PorkbunClient{ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), } fqdn, err := client.EnsureRandomARecord(context.Background(), "em-sysadmin.xyz", dnsPrefixForNode(*node), strings.TrimSpace(node.Host), 600) if err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } node.Domain = fqdn if node.Metadata == nil { node.Metadata = map[string]string{} } node.Metadata["dns_zone"] = "em-sysadmin.xyz" node.Metadata["dns_provider"] = "porkbun" if _, err := control.SaveNodeFile(h.inventoryDir(), *node); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } now := time.Now().UTC() state, _ := control.LoadNodeState(h.stateDir(), node.ID) if state == nil { state = &control.NodeState{ NodeID: node.ID, PublicHost: fqdn, Services: []control.ServiceStatus{}, Metadata: map[string]any{}, } } state.PublicHost = fqdn state.LastDNSSyncAt = &now if state.Metadata == nil { state.Metadata = map[string]any{} } state.Metadata["dns_provider"] = "porkbun" state.Metadata["dns_zone"] = "em-sysadmin.xyz" state.Metadata["dns_fqdn"] = fqdn if err := control.SaveNodeState(h.stateDir(), *state); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } writeJSON(w, map[string]any{ "provisioned": true, "fqdn": fqdn, "node": node, }) } func (h *Handler) ControlNodeDeleteDNS(w http.ResponseWriter, r *http.Request, nodeID string) { node, ok, err := h.loadNode(nodeID) if err != nil { log.Printf("error loading control node for dns delete: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } if !ok { http.NotFound(w, r) return } if strings.TrimSpace(node.Domain) == "" { http.Error(w, "node domain is empty", http.StatusBadRequest) return } name := strings.TrimSuffix(node.Domain, ".em-sysadmin.xyz") name = strings.TrimSuffix(name, ".") client := control.PorkbunClient{ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), } if err := client.DeleteARecord(context.Background(), "em-sysadmin.xyz", name); err != nil { http.Error(w, err.Error(), http.StatusBadGateway) return } writeJSON(w, map[string]any{ "deleted": true, "domain": node.Domain, }) } func (h *Handler) VPNUI(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write([]byte(vpnUIHTML)) } func (h *Handler) inventoryDir() string { return filepath.Join(h.store.DataDir(), "control", "inventory") } func (h *Handler) stateDir() string { return filepath.Join(h.store.DataDir(), "control", "state") } func (h *Handler) loadNode(nodeID string) (*control.Node, bool, error) { inventory, err := control.LoadInventoryDir(h.inventoryDir()) if err != nil { return nil, false, err } node, ok := inventory.NodeByID(nodeID) return node, ok, nil } func (h *Handler) findNodeByHost(host string) (*control.Node, error) { inventory, err := control.LoadInventoryDir(h.inventoryDir()) if err != nil { if control.IsNotExist(err) { return nil, nil } return nil, err } needle := normalizeHost(host) for idx := range inventory.Nodes { if normalizeHost(inventory.Nodes[idx].Host) == needle { return &inventory.Nodes[idx], nil } } return nil, nil } func buildQuickPreflightResponse(host string, data map[string]string) quickPreflightResponse { resp := quickPreflightResponse{ Host: host, SuggestedMultiName: generateQuickNodeName("auto", "multi", host), SuggestedSocksName: generateQuickNodeName("auto", "socks5", host), OSID: data["OS_ID"], OSPretty: data["OS_PRETTY"], OSLike: data["OS_LIKE"], SupportTier: classifySupportTier(data["OS_ID"], data["OS_LIKE"]), AlreadyManaged: data["MANAGED"] == "1", DockerInstalled: data["DOCKER"] == "1", ComposeAvailable: data["COMPOSE"] == "1", Ports: map[string]string{ "tcp_443": portStatusValue(data["TCP_443"]), "udp_443": portStatusValue(data["UDP_443"]), "tcp_54101": portStatusValue(data["TCP_54101"]), }, } resp.QuickMulti = quickPreflightDecision{Supported: true} resp.QuickSocks5 = quickPreflightDecision{Supported: true} if resp.AlreadyManaged { reason := "этот VPS уже управляется через vpnem; используйте настройки и действия с узлом вместо создания второго quick-узла" resp.HostStateLabel = "Уже под управлением" resp.QuickMulti = quickPreflightDecision{Supported: false, Reasons: []string{reason}} resp.QuickSocks5 = quickPreflightDecision{Supported: false, Reasons: []string{reason}} resp.Capabilities = append(resp.Capabilities, "Уже под управлением") resp.RecommendedAction = "Откройте существующий узел и используйте «Обновить сервер», «Починить сервер» или «Добавить SOCKS5»." } if resp.Ports["tcp_443"] == "busy" { resp.QuickMulti.Supported = false resp.QuickMulti.Reasons = append(resp.QuickMulti.Reasons, "TCP-порт 443 уже занят") } if resp.Ports["udp_443"] == "busy" { resp.QuickMulti.Supported = false resp.QuickMulti.Reasons = append(resp.QuickMulti.Reasons, "UDP-порт 443 уже занят") } if resp.Ports["tcp_54101"] == "busy" { resp.QuickSocks5.Supported = false resp.QuickSocks5.Reasons = append(resp.QuickSocks5.Reasons, "TCP-порт 54101 уже занят") } if !resp.AlreadyManaged { switch { case resp.QuickMulti.Supported && resp.QuickSocks5.Supported: resp.Capabilities = append(resp.Capabilities, "Можно ставить MULTI", "Можно ставить SOCKS5") resp.RecommendedAction = "Этот VPS выглядит чистым. По умолчанию выбирайте MULTI, если вам не нужен только простой SOCKS5-прокси." case resp.QuickMulti.Supported: resp.Capabilities = append(resp.Capabilities, "Можно ставить MULTI") resp.RecommendedAction = "Этот VPS готов для стандартной установки MULTI." case resp.QuickSocks5.Supported: resp.Capabilities = append(resp.Capabilities, "Можно ставить SOCKS5", "Конфликт портов для MULTI") resp.RecommendedAction = "Сейчас MULTI заблокирован, но SOCKS5 всё ещё безопасно ставить на порт 54101." default: resp.Capabilities = append(resp.Capabilities, "Быстрая установка заблокирована") resp.RecommendedAction = "У этого VPS есть конфликт портов или неподходящее состояние для быстрой установки. Сначала исправьте хост или используйте путь через настройки." } } if resp.SupportTier == "experimental" { resp.Warnings = append(resp.Warnings, "Этот дистрибутив считается экспериментальным. Для наиболее предсказуемой установки рекомендуются Debian или Ubuntu.") } if resp.SupportTier == "unknown" { resp.Warnings = append(resp.Warnings, "Этот дистрибутив неизвестен для vpnui. Установка может сработать, но он не входит в рекомендуемую матрицу поддержки.") } if !resp.DockerInstalled { resp.Warnings = append(resp.Warnings, "Docker ещё не установлен. vpnui попробует установить его во время bootstrap.") } if !resp.ComposeAvailable { resp.Warnings = append(resp.Warnings, "Docker Compose пока недоступен. vpnui попробует установить его или использовать совместимый путь во время bootstrap.") } if resp.HostStateLabel == "" { switch { case resp.QuickMulti.Supported: resp.HostStateLabel = "VPS чистый" case resp.QuickSocks5.Supported: resp.HostStateLabel = "Можно поставить SOCKS5" default: resp.HostStateLabel = "Установка заблокирована" } } return resp } func generateQuickNodeName(region, kind, host string) string { adjectives := []string{"Maple", "Quartz", "Harbor", "Comet", "Cedar", "Nova", "Atlas", "Echo"} area := strings.ToUpper(strings.TrimSpace(region)) if area == "" || area == "AUTO" { area = "AUTO" } kindLabel := "Server" switch kind { case "multi": kindLabel = "Multi" case "socks5": kindLabel = "SOCKS5" } word := adjectives[0] if host != "" { sum := 0 for _, r := range host { sum += int(r) } word = adjectives[sum%len(adjectives)] } suffix := "01" if part, err := controlRandomHex(1); err == nil && part != "" { suffix = strings.ToUpper(part) } return area + " " + kindLabel + " " + word + " " + suffix } func classifySupportTier(osID, osLike string) string { combined := strings.ToLower(strings.TrimSpace(osID + " " + osLike)) switch { case strings.Contains(combined, "debian") || strings.Contains(combined, "ubuntu"): return "recommended" case strings.Contains(combined, "rhel") || strings.Contains(combined, "rocky") || strings.Contains(combined, "alma") || strings.Contains(combined, "centos") || strings.Contains(combined, "fedora"): return "supported" case strings.Contains(combined, "arch") || strings.Contains(combined, "alpine"): return "experimental" default: return "unknown" } } func portStatusValue(value string) string { switch strings.ToLower(strings.TrimSpace(value)) { case "1", "busy": return "busy" case "0", "free": return "free" default: return "unknown" } } func (h *Handler) authorizeAdmin(w http.ResponseWriter, r *http.Request) bool { token := strings.TrimSpace(os.Getenv("VPNEM_ADMIN_TOKEN")) if token == "" { return true } provided := strings.TrimSpace(r.Header.Get("X-Admin-Token")) if provided == "" { provided = strings.TrimSpace(r.URL.Query().Get("token")) } if provided != token { http.Error(w, "unauthorized", http.StatusUnauthorized) return false } return true } func validateNodeForUI(node control.Node) error { for idx := range node.Protocols { if err := control.EnsureProtocolForUI(&node.Protocols[idx]); err != nil { return err } } if err := control.ValidateNode(node); err != nil { return err } for _, protocol := range node.Protocols { switch protocol.Type { case "vless": if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { return errors.New("vless protocol requires auth.uuid") } case "vless-reality": if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { return errors.New("vless-reality protocol requires auth.uuid") } if protocol.Reality == nil || strings.TrimSpace(protocol.Reality.ServerName) == "" { return errors.New("vless-reality protocol requires reality.server_name") } case "vmess": if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { return errors.New("vmess protocol requires auth.uuid") } case "shadowsocks": if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" { return errors.New("shadowsocks protocol requires auth.method and auth.password") } case "hysteria2": if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" { return errors.New("hysteria2 protocol requires auth.password") } } } return nil } func decodeActionRequest(r *http.Request) (actionRequest, error) { var req actionRequest if r.Body == nil || r.ContentLength == 0 { return req, nil } err := json.NewDecoder(r.Body).Decode(&req) if errors.Is(err, http.ErrBodyReadAfterClose) { return req, nil } return req, err } func applyActionPassword(node *control.Node, req actionRequest) *control.Node { if node == nil { return nil } copy := *node copy.SSH.Password = strings.TrimSpace(req.SSHPassword) return © } func buildQuickProvisionNode(req quickProvisionRequest) (control.Node, string, error) { host := strings.TrimSpace(req.Host) password := strings.TrimSpace(req.RootPassword) if host == "" { return control.Node{}, "", errors.New("host is required") } if password == "" { return control.Node{}, "", errors.New("root_password is required") } region := strings.TrimSpace(req.Region) if region == "" { region = "auto" } provider := strings.TrimSpace(req.Provider) if provider == "" { provider = "custom-vps" } acmeEmail := strings.TrimSpace(req.ACMEEmail) if acmeEmail == "" { acmeEmail = "admin@em-sysadmin.xyz" } enableMulti := req.EnableMulti || (req.EnableReality && req.EnableHY2) nodeKind := "server" if req.EnableSocks && !enableMulti && !req.EnableSS && !req.EnableVMess && !req.EnableVLESS && !req.EnableReality && !req.EnableHY2 { nodeKind = "socks5" } else if enableMulti { nodeKind = "multi" } nodeID := "node-" + strings.ReplaceAll(host, ".", "-") if suffix, err := controlRandomHex(2); err == nil { nodeID += "-" + suffix } uuid, err := controlRandomUUID() if err != nil { return control.Node{}, "", err } vmessUUID, err := controlRandomUUID() if err != nil { return control.Node{}, "", err } ssPassword, err := controlRandomHex(16) if err != nil { return control.Node{}, "", err } hy2Password, err := controlRandomBase64(16) if err != nil { return control.Node{}, "", err } hy2ObfsPassword, err := controlRandomHex(32) if err != nil { return control.Node{}, "", err } protocols := make([]control.ProtocolProfile, 0, 3) if req.EnableVLESS { protocols = append(protocols, control.ProtocolProfile{ Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{ Enabled: true, }, Auth: &control.AuthProfile{ UUID: uuid, }, Extra: map[string]any{ "transport_type": "ws", "path": "/ws", }, }) } if req.EnableReality && !enableMulti { protocols = append(protocols, control.ProtocolProfile{ Type: "vless-reality", Enabled: true, Port: 443, Auth: &control.AuthProfile{ UUID: uuid, }, Reality: &control.VLESSRealityProfile{ ServerName: "www.nokia.com", ServerPort: 443, Fingerprint: "chrome", }, }) } if req.EnableSocks { protocols = append(protocols, control.ProtocolProfile{ Type: "socks5", Enabled: true, Port: 54101, }) } if req.EnableSS { protocols = append(protocols, control.ProtocolProfile{ Type: "shadowsocks", Enabled: true, Port: 8443, Auth: &control.AuthProfile{ Method: "2022-blake3-aes-128-gcm", Password: ssPassword, }, }) } if req.EnableVMess { protocols = append(protocols, control.ProtocolProfile{ Type: "vmess", Enabled: true, Port: 8444, TLS: &control.TLSProfile{ Enabled: true, }, Auth: &control.AuthProfile{ UUID: vmessUUID, }, Extra: map[string]any{ "path": "/vmess", }, }) } if req.EnableHY2 && !enableMulti { protocols = append(protocols, control.ProtocolProfile{ Type: "hysteria2", Enabled: true, Port: 443, Auth: &control.AuthProfile{ Password: hy2Password, }, Hysteria2: &control.Hysteria2Profile{ Port: 443, UpMbps: 100, DownMbps: 100, ObfsPassword: hy2ObfsPassword, UserPassword: hy2Password, CertPath: "/etc/sing-box/cert.pem", KeyPath: "/etc/sing-box/key.pem", }, }) } if enableMulti { protocols = append(protocols, control.ProtocolProfile{ Type: "vless-reality", Enabled: true, Port: 443, Auth: &control.AuthProfile{ UUID: uuid, }, Reality: &control.VLESSRealityProfile{ ServerName: "www.nokia.com", ServerPort: 443, Fingerprint: "chrome", }, }, control.ProtocolProfile{ Type: "hysteria2", Enabled: true, Port: 443, Auth: &control.AuthProfile{ Password: hy2Password, }, Hysteria2: &control.Hysteria2Profile{ Port: 443, UpMbps: 100, DownMbps: 100, ObfsPassword: hy2ObfsPassword, UserPassword: hy2Password, CertPath: "/etc/sing-box/cert.pem", KeyPath: "/etc/sing-box/key.pem", }, }) } if len(protocols) == 0 { return control.Node{}, "", errors.New("at least one protocol must be enabled") } node := control.Node{ ID: nodeID, Name: generateQuickNodeName(region, nodeKind, host), Provider: provider, Region: region, Host: host, ACMEEmail: acmeEmail, Enabled: true, SSH: control.SSHConfig{ User: "root", Port: 22, Auth: "password", PasswordEnv: "VPNEM_RUNTIME_PASSWORD", Password: password, }, Protocols: protocols, Metadata: map[string]string{ "provision_mode": "quick", }, } return node, password, nil } func (h *Handler) provisionNodeFlow(ctx context.Context, node *control.Node, sshPassword string) (map[string]any, error) { response := map[string]any{ "node_id": node.ID, } if sshPassword != "" { node.SSH.Password = sshPassword } if strings.TrimSpace(node.Domain) == "" && nodeNeedsProvisionedDNS(*node) { client := control.PorkbunClient{ APIKey: strings.TrimSpace(os.Getenv("PORKBUN_API_KEY")), SecretAPIKey: strings.TrimSpace(os.Getenv("PORKBUN_SECRET_API_KEY")), } fqdn, err := client.EnsureRandomARecord(ctx, "em-sysadmin.xyz", dnsPrefixForNode(*node), strings.TrimSpace(node.Host), 600) if err != nil { return nil, err } node.Domain = fqdn for idx := range node.Protocols { if node.Protocols[idx].TLS != nil && node.Protocols[idx].TLS.Enabled { node.Protocols[idx].TLS.ServerName = fqdn } } if node.Metadata == nil { node.Metadata = map[string]string{} } node.Metadata["dns_zone"] = "em-sysadmin.xyz" node.Metadata["dns_provider"] = "porkbun" if _, err := control.SaveNodeFile(h.inventoryDir(), *node); err != nil { return nil, err } now := time.Now().UTC() state, _ := control.LoadNodeState(h.stateDir(), node.ID) if state == nil { state = &control.NodeState{ NodeID: node.ID, PublicHost: fqdn, Services: []control.ServiceStatus{}, Metadata: map[string]any{}, } } state.PublicHost = fqdn state.LastDNSSyncAt = &now if state.Metadata == nil { state.Metadata = map[string]any{} } state.Metadata["dns_provider"] = "porkbun" state.Metadata["dns_zone"] = "em-sysadmin.xyz" state.Metadata["dns_fqdn"] = fqdn if err := control.SaveNodeState(h.stateDir(), *state); err != nil { return nil, err } response["dns"] = map[string]any{ "provisioned": true, "fqdn": fqdn, } } else { response["dns"] = map[string]any{ "provisioned": false, "fqdn": node.Domain, "skipped": dnsSkipReason(*node), } } savedPath, err := control.SaveNodeFile(h.inventoryDir(), *node) if err != nil { return nil, err } savedNode, err := control.LoadNodeFile(savedPath) if err != nil { return nil, err } *node = *savedNode if sshPassword != "" { node.SSH.Password = sshPassword } bootstrapState, err := control.BootstrapNode(ctx, control.SSHRunner{}, *node, control.BootstrapOptions{ StateDir: h.stateDir(), DryRun: false, }) if err != nil { return nil, err } response["bootstrap"] = bootstrapState checkState, err := control.CheckNode(ctx, control.SSHRunner{}, *node, h.stateDir()) if err != nil { return nil, err } response["check"] = checkState published := false if canPublishNodeState(*checkState) { inventory, err := control.LoadInventoryDir(h.inventoryDir()) if err != nil { return nil, err } states := make(map[string]*control.NodeState, len(inventory.Nodes)) for _, item := range inventory.Nodes { state, err := control.LoadNodeState(h.stateDir(), item.ID) if err != nil { if control.IsNotExist(err) { continue } return nil, err } states[item.ID] = state } publishable := control.PublishableNodes(inventory.Nodes, states) target := filepath.Join(h.store.DataDir(), "servers.json") if err := control.WriteLegacyCatalog(target, publishable); err != nil { return nil, err } catalogTarget := filepath.Join(h.store.DataDir(), "catalog-v2.json") if err := control.WriteCatalogV2(catalogTarget, publishable, states); err != nil { return nil, err } published = true response["publish"] = map[string]any{ "published": true, "target": target, "catalog_v2_target": catalogTarget, "count": len(publishable), } } else { response["publish"] = map[string]any{ "published": false, "reason": "узел ещё не готов и не healthy", } } response["ready_for_catalog"] = published return response, nil } func controlRandomHex(size int) (string, error) { return control.RandomHexForAPI(size) } func controlRandomBase64(size int) (string, error) { return control.RandomBase64ForAPI(size) } func controlRandomUUID() (string, error) { return control.RandomUUIDForAPI() } func nodeNeedsProvisionedDNS(node control.Node) bool { for _, protocol := range node.Protocols { if !protocol.Enabled { continue } switch protocol.Type { case "vless": if protocol.TLS != nil && protocol.TLS.Enabled { return true } case "vmess": if protocol.TLS != nil && protocol.TLS.Enabled { return true } } } return false } func dnsSkipReason(node control.Node) string { if strings.TrimSpace(node.Domain) != "" { return "домен уже задан" } return "selected protocols do not require a public domain" } const vpnUIHTML = ` Панель управления vpnem
Панель VPN

Один экран для установки, ремонта и управления VPN-узлами.

Сверху находится простой путь: вставьте IP сервера, введите root-пароль и получите готовый узел. Ниже остаётся тонкая настройка на тот случай, если нужно чинить, обновлять, добавлять SOCKS5 или вручную переопределять параметры.
Шаг 1
Проверьте VPS и убедитесь, что панель говорит, можно ли ставить MULTI или SOCKS5.
Шаг 2
Создайте узел, дождитесь проверки и сразу скопируйте готовую ссылку из карточек справа.
Тонкая настройка
Если VPS уже под управлением, спускайтесь ниже: там есть обновление, ремонт, чистая переустановка и ручные override-поля.

Быстрая установка

Это главный путь. Если нужен просто рабочий сервер, оставайтесь здесь: сначала проверка VPS, потом установка, потом копирование готовых ссылок.

Быстрая установка

Это самый простой сценарий. Вставьте IP сервера, введите root-пароль, выберите тип узла и дождитесь, пока панель сама всё сделает: установит, проверит и покажет готовые ссылки.
Как это работает
1. Нажмите «Проверить VPS».
2. Оставьте включённым MULTI, если нужен основной современный режим.
3. Нажмите «Создать прокси».
4. Скопируйте готовую ссылку из блока ниже.
Дополнительно Все поля уже заполнены по умолчанию
Выберите готовый сценарий
Сейчас выбран сценарий Обычный сервер.
Если не уверены, оставляйте пресет Обычный сервер. Для SOCKS5 по умолчанию используется порт 54101.
Что заполнится автоматически
Имя сервера будет создано само.
Для MULTI по умолчанию используются уже проверенные настройки: TCP через REALITY, UDP через Hysteria2, порт 443, рабочий SNI и безопасные transport-параметры.
Для SOCKS5 по умолчанию используется порт 54101.
Готово.
Если панель доступна публично, не отключайте админ-токен. Главная вкладка рассчитана на простой сценарий с IP и паролем.
Сейчас в системе
Пока нет ни одного сервера.
Начните с проверки VPS, затем создайте первый сервер и скопируйте готовую ссылку.

Мои серверы

Подключение

Используйте этот блок после того, как узел станет healthy и готовым к публикации. Здесь находятся ссылки и параметры подключения для прямого копирования.
Сырая сводка

            

Тонкая настройка и сервисные действия

Этот блок нужен, когда узел уже существует и его надо обновлять, ремонтировать, дооснащать SOCKS5 или вручную править конфиг.

Что здесь можно делать

Выбирайте узел в списке выше и используйте основные действия. Ручные поля протоколов нужны редко и остаются ниже как сервисный слой.
Доступ
Панель можно открывать по magic-link вроде ` + "`/vpnui/?token=...`" + ` или ` + "`/vpnui/#token=...`" + `. Ключ будет сохранён в этом браузере и удалён из URL.

Данные узла

Базовые данные о VPS и публичном хосте, который будут использовать клиенты.
Используйте настройки только тогда, когда простого сценария “Создать прокси” уже недостаточно и нужен точный контроль над узлом.

Доступ к серверу

Как ` + "`vpnui`" + ` должен входить на VPS для bootstrap, проверок, обновления и удаления узла.
Если сервер использует вход по паролю, введите текущий root-пароль перед bootstrap или проверками. Он отправляется только вместе с действием и не сохраняется в inventory.

Основные действия

Используйте эти кнопки для обычного обслуживания уже существующего узла. Сырые поля протоколов ниже обычно трогать не нужно.
Выберите узел, чтобы увидеть самый безопасный следующий шаг.
Используйте Добавить SOCKS5, когда MULTI-узел уже работает и вы хотите добавить fallback-прокси на порту 54101 на том же VPS.
Ручные переопределения протоколов Нужно только если вы точно знаете, что меняете

Протоколы

Этот раздел нужен только для ручных переопределений. В обычном сценарии достаточно быстрой установки на главной вкладке и основных действий выше.
VLESS
VLESS REALITY
REALITY использует server-side reality TLS внутри sing-box и не требует публичный домен или ACME-сертификаты.
Shadowsocks
SOCKS5
Простой прямой SOCKS5 listener без TLS-слоя.
VMess
Hysteria2

Сохранение и жизненный цикл

Сохраняйте ручные изменения только тогда, когда вы специально меняли конфигурацию узла. Ниже остаются расширенные действия для DNS и разрушительных операций.
Расширенные действия
Техническая диагностика

        
` 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) }