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