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 = `
Это главный путь. Если нужен просто рабочий сервер, оставайтесь здесь: сначала проверка VPS, потом установка, потом копирование готовых ссылок.
54101.443, рабочий SNI и безопасные transport-параметры.54101.
Этот блок нужен, когда узел уже существует и его надо обновлять, ремонтировать, дооснащать SOCKS5 или вручную править конфиг.