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 ++++++++++++++++++++++++++++++++++++++++ internal/api/control_test.go | 297 ++++ internal/api/handlers.go | 345 ++++ internal/api/handlers_test.go | 592 +++++++ internal/api/middleware.go | 70 + internal/api/recommend_test.go | 549 ++++++ internal/api/router.go | 80 + internal/api/subscribe.go | 288 +++ 8 files changed, 6002 insertions(+) create mode 100644 internal/api/control.go create mode 100644 internal/api/control_test.go create mode 100644 internal/api/handlers.go create mode 100644 internal/api/handlers_test.go create mode 100644 internal/api/middleware.go create mode 100644 internal/api/recommend_test.go create mode 100644 internal/api/router.go create mode 100644 internal/api/subscribe.go (limited to 'internal/api') 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) +} diff --git a/internal/api/control_test.go b/internal/api/control_test.go new file mode 100644 index 0000000..336aa52 --- /dev/null +++ b/internal/api/control_test.go @@ -0,0 +1,297 @@ +package api + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/control" + "vpnem/internal/models" + "vpnem/internal/rules" +) + +func TestCanPublishNodeState(t *testing.T) { + tests := []struct { + name string + state control.NodeState + want bool + }{ + {name: "healthy", state: control.NodeState{BootstrapStatus: "healthy"}, want: true}, + {name: "ready", state: control.NodeState{BootstrapStatus: "ready"}, want: true}, + {name: "planned", state: control.NodeState{BootstrapStatus: "planned"}, want: false}, + {name: "failed", state: control.NodeState{BootstrapStatus: "failed"}, want: false}, + {name: "unreachable", state: control.NodeState{BootstrapStatus: "unreachable"}, want: false}, + {name: "healthy services", state: control.NodeState{ + BootstrapStatus: "healthy", + Services: []control.ServiceStatus{{Type: "socks5", Status: "running", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 200}, + }, want: true}, + {name: "degraded services", state: control.NodeState{ + BootstrapStatus: "healthy", + Services: []control.ServiceStatus{{Type: "socks5", Status: "unknown", Port: 1080}}, + Metadata: map[string]any{"healthz_http_code": 503}, + }, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := canPublishNodeState(tt.state) + if got != tt.want { + t.Fatalf("canPublishNodeState(%+v) = %v, want %v", tt.state, got, tt.want) + } + }) + } +} + +func setupControlTestStore(t *testing.T) *rules.Store { + t.Helper() + dir := t.TempDir() + + writeJSON := func(name string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + + writeJSON("servers.json", models.ServersResponse{Servers: []models.Server{}}) + writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}}) + writeJSON("version.json", models.VersionResponse{Version: "test"}) + writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"}) + + if err := os.MkdirAll(filepath.Join(dir, "control", "inventory"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "control", "state"), 0o755); err != nil { + t.Fatal(err) + } + + return rules.NewStore(dir) +} + +func TestBuildQuickProvisionNode(t *testing.T) { + node, password, err := buildQuickProvisionNode(quickProvisionRequest{ + Host: "89.124.96.166", + RootPassword: "secret", + EnableMulti: true, + EnableSocks: true, + }) + if err != nil { + t.Fatalf("buildQuickProvisionNode() error = %v", err) + } + if password != "secret" { + t.Fatalf("password = %q, want secret", password) + } + if node.Host != "89.124.96.166" { + t.Fatalf("node.Host = %q", node.Host) + } + if !strings.Contains(node.Name, "Multi") { + t.Fatalf("node.Name = %q, want generated multi-style name", node.Name) + } + if node.SSH.Auth != "password" { + t.Fatalf("node.SSH.Auth = %q, want password", node.SSH.Auth) + } + if node.SSH.PasswordEnv == "" { + t.Fatal("node.SSH.PasswordEnv should be set for persisted quick-provision nodes") + } + if node.SSH.Password != "secret" { + t.Fatalf("node.SSH.Password mismatch") + } + if len(node.Protocols) != 3 { + t.Fatalf("expected 3 protocols, got %d", len(node.Protocols)) + } + seen := map[string]int{} + for _, protocol := range node.Protocols { + seen[protocol.Type] = protocol.Port + } + if seen["vless-reality"] != 443 { + t.Fatalf("vless-reality port = %d, want 443", seen["vless-reality"]) + } + if seen["hysteria2"] != 443 { + t.Fatalf("hysteria2 port = %d, want 443", seen["hysteria2"]) + } + if seen["socks5"] != 54101 { + t.Fatalf("socks5 port = %d, want 54101", seen["socks5"]) + } +} + +func TestBuildQuickProvisionNodeReality(t *testing.T) { + node, _, err := buildQuickProvisionNode(quickProvisionRequest{ + Host: "89.124.96.166", + RootPassword: "secret", + EnableReality: true, + }) + if err != nil { + t.Fatalf("buildQuickProvisionNode() error = %v", err) + } + if len(node.Protocols) != 1 { + t.Fatalf("expected 1 protocol, got %d", len(node.Protocols)) + } + if node.Protocols[0].Type != "vless-reality" { + t.Fatalf("protocol type = %q, want vless-reality", node.Protocols[0].Type) + } + if node.Protocols[0].Reality == nil || node.Protocols[0].Reality.ServerName == "" { + t.Fatal("expected reality defaults to be set") + } +} + +func TestNodeNeedsProvisionedDNS(t *testing.T) { + realityOnly := control.Node{ + Protocols: []control.ProtocolProfile{ + {Type: "vless-reality", Enabled: true, Port: 443}, + }, + } + if nodeNeedsProvisionedDNS(realityOnly) { + t.Fatal("did not expect DNS requirement for vless-reality-only node") + } + + wsNode := control.Node{ + Protocols: []control.ProtocolProfile{ + {Type: "vless", Enabled: true, Port: 443, TLS: &control.TLSProfile{Enabled: true}}, + }, + } + if !nodeNeedsProvisionedDNS(wsNode) { + t.Fatal("expected DNS requirement for tls-enabled vless node") + } +} + +func TestVPNUIIncludesReinstallActions(t *testing.T) { + if !strings.Contains(vpnUIHTML, "Начать установку") { + t.Fatal("expected installer-style quick action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Открыть тонкую настройку") { + t.Fatal("expected advanced jump action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Быстрая установка") { + t.Fatal("expected installer-like quick install heading in vpnui") + } + if !strings.Contains(vpnUIHTML, "Тонкая настройка и сервисные действия") { + t.Fatal("expected unified advanced section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Что заполнится автоматически") { + t.Fatal("expected auto defaults explanation in vpnui") + } + if !strings.Contains(vpnUIHTML, "Починить сервер") { + t.Fatal("expected russian repair action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Переустановить сервер") { + t.Fatal("expected russian reinstall action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Проверить VPS") { + t.Fatal("expected russian inspect vps action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Добавить SOCKS5") { + t.Fatal("expected russian Add SOCKS5 action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Удалить сервер") { + t.Fatal("expected russian delete server action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Основные действия") { + t.Fatal("expected russian primary actions section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Ручные переопределения протоколов") { + t.Fatal("expected russian operator protocol overrides section in vpnui") + } + if !strings.Contains(vpnUIHTML, "Что можно сделать здесь") { + t.Fatal("expected russian guide in vpnui") + } + if !strings.Contains(vpnUIHTML, "Выберите узел, чтобы увидеть самый безопасный следующий шаг.") { + t.Fatal("expected russian node guide placeholder in vpnui") + } + if !strings.Contains(vpnUIHTML, "Можно ставить MULTI") { + t.Fatal("expected russian quick status rail labels in vpnui") + } + if !strings.Contains(vpnUIHTML, "Готов к публикации") { + t.Fatal("expected russian node status rail labels in vpnui") + } + if !strings.Contains(vpnUIHTML, "data-fleet-filter=\"ready\"") { + t.Fatal("expected node fleet filters in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сейчас ни один узел не подходит под этот фильтр.") { + t.Fatal("expected russian filtered empty state in vpnui") + } + if !strings.Contains(vpnUIHTML, "Копировать URI") { + t.Fatal("expected russian copy uri action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Копировать детали") { + t.Fatal("expected russian copy details action in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сейчас в системе") { + t.Fatal("expected simplified current system summary in vpnui") + } + if !strings.Contains(vpnUIHTML, "Сервер работает") { + t.Fatal("expected product-oriented node card language in vpnui") + } +} + +func TestFindNodeByHost(t *testing.T) { + store := setupControlTestStore(t) + handler := &Handler{store: store} + + if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ + ID: "nl-01", + Name: "NL 01", + Provider: "custom-vps", + Region: "nl", + Host: "89.124.96.166", + Enabled: true, + SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []control.ProtocolProfile{ + {Type: "socks5", Enabled: true, Port: 54101}, + }, + }); err != nil { + t.Fatal(err) + } + + node, err := handler.findNodeByHost("89.124.96.166") + if err != nil { + t.Fatalf("findNodeByHost() error = %v", err) + } + if node == nil || node.ID != "nl-01" { + t.Fatalf("findNodeByHost() = %+v, want nl-01", node) + } +} + +func TestBuildQuickPreflightResponse(t *testing.T) { + resp := buildQuickPreflightResponse("89.124.96.166", map[string]string{ + "OS_ID": "ubuntu", + "OS_PRETTY": "Ubuntu 24.04 LTS", + "MANAGED": "0", + "DOCKER": "1", + "COMPOSE": "1", + "TCP_443": "0", + "UDP_443": "1", + "TCP_54101": "0", + }) + + if resp.SupportTier != "recommended" { + t.Fatalf("SupportTier = %q, want recommended", resp.SupportTier) + } + if resp.QuickMulti.Supported { + t.Fatal("expected quick multi to be blocked by busy UDP 443") + } + if resp.QuickSocks5.Supported != true { + t.Fatal("expected quick socks5 to stay supported") + } + if got := resp.Ports["udp_443"]; got != "busy" { + t.Fatalf("udp_443 = %q, want busy", got) + } + if len(resp.Capabilities) < 2 || resp.Capabilities[0] != "Можно ставить SOCKS5" || resp.Capabilities[1] != "Конфликт портов для MULTI" { + t.Fatalf("Capabilities = %v, want russian socks5 + multi conflict labels", resp.Capabilities) + } + if resp.HostStateLabel != "Можно поставить SOCKS5" { + t.Fatalf("HostStateLabel = %q, want Russian SOCKS5-only state", resp.HostStateLabel) + } + if resp.SuggestedMultiName == "" || resp.SuggestedSocksName == "" { + t.Fatalf("expected suggested names to be generated, got multi=%q socks=%q", resp.SuggestedMultiName, resp.SuggestedSocksName) + } + if !strings.Contains(resp.RecommendedAction, "SOCKS5") { + t.Fatalf("RecommendedAction = %q, want SOCKS5 hint", resp.RecommendedAction) + } +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..8749646 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,345 @@ +package api + +import ( + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "vpnem/internal/models" + "vpnem/internal/rules" +) + +type Handler struct { + store *rules.Store +} + +func NewHandler(store *rules.Store) *Handler { + return &Handler{store: store} +} + +func (h *Handler) Servers(w http.ResponseWriter, r *http.Request) { + servers, err := h.store.LoadServers() + if err != nil { + log.Printf("error loading servers: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, servers) +} + +func (h *Handler) RuleSetManifest(w http.ResponseWriter, r *http.Request) { + manifest, err := h.store.LoadRuleSets() + if err != nil { + log.Printf("error loading rulesets: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, manifest) +} + +func (h *Handler) Version(w http.ResponseWriter, r *http.Request) { + ver, err := h.store.LoadVersion() + if err != nil { + log.Printf("error loading version: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, ver) +} + +func (h *Handler) CatalogV2(w http.ResponseWriter, r *http.Request) { + catalog, err := h.store.LoadCatalogV2OrLegacy() + if err != nil { + if os.IsNotExist(err) { + http.Error(w, "catalog-v2 not found", http.StatusNotFound) + return + } + log.Printf("error loading catalog-v2: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, catalog) +} + +func (h *Handler) RoutingPolicy(w http.ResponseWriter, r *http.Request) { + policy, err := h.store.LoadRoutingPolicy() + if err != nil { + log.Printf("error loading routing policy: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + writeJSON(w, policy) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + log.Printf("error encoding json: %v", err) + } +} + +// ClientLog receives error logs from vpnem clients. +// POST /logs2026vpnem/errors with JSON body: {"version":"2.0.11","os":"windows","lines":["..."]} +func (h *Handler) ClientLog(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > 64*1024 { + http.Error(w, "too large", http.StatusRequestEntityTooLarge) + return + } + body, err := io.ReadAll(io.LimitReader(r.Body, 64*1024)) + r.Body.Close() + if err != nil { + http.Error(w, "read error", http.StatusBadRequest) + return + } + + logDir := filepath.Join(h.store.DataDir(), "client-logs") + if err := os.MkdirAll(logDir, 0755); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + stamp := time.Now().UTC().Format("2006-01-02T15-04-05") + src := r.RemoteAddr + if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" { + src = fwd + } + filename := fmt.Sprintf("%s_%s.log", stamp, src) + if err := os.WriteFile(filepath.Join(logDir, filename), body, 0644); err != nil { + http.Error(w, "write error", http.StatusInternalServerError) + return + } + log.Printf("client log saved: %s (%d bytes)", filename, len(body)) + w.WriteHeader(http.StatusAccepted) +} + +// ClientLogsViewer shows a simple HTML page listing all client error logs. +func (h *Handler) ClientLogsViewer(w http.ResponseWriter, r *http.Request) { + logDir := filepath.Join(h.store.DataDir(), "client-logs") + + // Check for file view request + viewFile := r.URL.Query().Get("file") + if viewFile != "" { + safeName := filepath.Base(viewFile) + data, err := os.ReadFile(filepath.Join(logDir, safeName)) + if err != nil { + http.Error(w, "file not found", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Write(data) + return + } + + // List all log files + entries, err := os.ReadDir(logDir) + if err != nil { + entries = nil + } + + var rows string + for i := len(entries) - 1; i >= 0; i-- { + e := entries[i] + if e.IsDir() || !strings.HasSuffix(e.Name(), ".log") { + continue + } + info, _ := e.Info() + size := info.Size() + rows += fmt.Sprintf(`%s%s%d B`, + e.Name(), e.Name(), info.ModTime().Format("2006-01-02 15:04"), size) + } + + html := fmt.Sprintf(`Client Error Logs + +

📋 Client Error Logs

+

Files from vpnem clients that reported errors.

+%s +`, func() string { + if rows == "" { + return `
No client error logs yet.
` + } + return `` + rows + `
FileModifiedSize
` + }()) + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Write([]byte(html)) +} + +// ClientConnect records a client connection. Server auto-detects real IP via RealIP middleware. +// POST /api/v1/connect with JSON body: {"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"} +func (h *Handler) ClientConnect(w http.ResponseWriter, r *http.Request) { + clientIP := GetRealIP(r) + if clientIP == "" { + log.Printf("connect: could not determine client IP, remote=%s", r.RemoteAddr) + http.Error(w, "could not determine client IP", http.StatusBadRequest) + return + } + + var req models.ConnectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + log.Printf("connect: invalid request body from %s: %v", clientIP, err) + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.ServerIP == "" { + log.Printf("connect: missing server_ip from %s", clientIP) + http.Error(w, "server_ip is required", http.StatusBadRequest) + return + } + + h.store.Connections().Connect(clientIP, req.ServerIP, req.NodeID, req.OS, req.Version) + log.Printf("connect: %s → %s (%s)", clientIP, req.ServerIP, req.NodeID) + + // Return updated recommendation for NEXT client + availableIPs := h.getAvailableServerIPs() + healthyIPs := h.getHealthyServerIPs() + recommendation := h.store.Connections().GetRecommendation(clientIP, availableIPs, healthyIPs) + log.Printf("connect: recommendation for %s → %s (%s)", clientIP, recommendation.RecommendedServerIP, recommendation.Reason) + + writeJSON(w, recommendation) +} + +// ClientDisconnect records a client disconnection. +// POST /api/v1/disconnect with JSON body: {"server_ip":"5.180.97.198","node_id":"nl-198"} +func (h *Handler) ClientDisconnect(w http.ResponseWriter, r *http.Request) { + clientIP := GetRealIP(r) + if clientIP == "" { + log.Printf("disconnect: could not determine client IP, remote=%s", r.RemoteAddr) + http.Error(w, "could not determine client IP", http.StatusBadRequest) + return + } + + var req models.DisconnectRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // Allow empty body — just use client IP + h.store.Connections().Disconnect(clientIP) + log.Printf("disconnect: %s (empty body)", clientIP) + writeJSON(w, map[string]string{"status": "disconnected"}) + return + } + + h.store.Connections().Disconnect(clientIP) + log.Printf("disconnect: %s from %s (%s)", clientIP, req.ServerIP, req.NodeID) + writeJSON(w, map[string]string{"status": "disconnected"}) +} + +// Recommend returns the recommended server for a client based on their real IP. +// GET /api/v1/recommend — server auto-detects client IP from X-Forwarded-For. +func (h *Handler) Recommend(w http.ResponseWriter, r *http.Request) { + clientIP := GetRealIP(r) + if clientIP == "" { + log.Printf("recommend: could not determine client IP, remote=%s", r.RemoteAddr) + http.Error(w, "could not determine client IP", http.StatusBadRequest) + return + } + + availableIPs := h.getAvailableServerIPs() + healthyIPs := h.getHealthyServerIPs() + recommendation := h.store.Connections().GetRecommendation(clientIP, availableIPs, healthyIPs) + log.Printf("recommend: %s → %s (%s)", clientIP, recommendation.RecommendedServerIP, recommendation.Reason) + + writeJSON(w, recommendation) +} + +// getHealthyServerIPs returns a set of server IPs that are considered healthy. +// For now, all available IPs are considered healthy. +// This can be extended to check node health states from the control plane. +func (h *Handler) getHealthyServerIPs() map[string]bool { + ips := h.getAvailableServerIPs() + healthy := make(map[string]bool) + for _, ip := range ips { + healthy[ip] = true + } + return healthy +} + +// getAvailableServerIPs extracts unique server IPs from nodes that have MULTI protocols. +// Only MULTI-capable nodes (vless-reality + hysteria2) are included in the recommendation pool. +// SOCKS5-only nodes are excluded — they exist as fallback but are never recommended. +func (h *Handler) getAvailableServerIPs() []string { + catalog, err := h.store.LoadCatalogV2OrLegacy() + if err != nil { + return nil + } + + seen := make(map[string]bool) + var ips []string + + for _, node := range catalog.Nodes { + // Skip nodes that don't have MULTI protocols + if !hasMultiProtocol(node) { + continue + } + + host := node.PublicHost + if host == "" { + if node.Domain != "" { + host = node.Domain + } else { + host = node.Host + } + } + // Only include IP addresses, skip hostnames + if host != "" && isIPAddress(host) && !seen[host] { + seen[host] = true + ips = append(ips, host) + } + } + + sort.Strings(ips) + return ips +} + +// hasMultiProtocol checks if a node has MULTI protocols (vless-reality + hysteria2). +func hasMultiProtocol(node models.CatalogNode) bool { + hasReality := false + hasHy2 := false + for _, p := range node.Protocols { + if !p.Enabled { + continue + } + if p.Type == "vless-reality" { + hasReality = true + } + if p.Type == "hysteria2" { + hasHy2 = true + } + } + return hasReality && hasHy2 +} + +func isIPAddress(s string) bool { + // Simple IPv4 check: X.X.X.X where X is 1-3 digits + parts := strings.Split(s, ".") + if len(parts) != 4 { + return false + } + for _, part := range parts { + if len(part) == 0 || len(part) > 3 { + return false + } + for _, c := range part { + if c < '0' || c > '9' { + return false + } + } + } + return true +} diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go new file mode 100644 index 0000000..262ea07 --- /dev/null +++ b/internal/api/handlers_test.go @@ -0,0 +1,592 @@ +package api_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/api" + "vpnem/internal/control" + "vpnem/internal/models" + "vpnem/internal/rules" +) + +func setupTestStore(t *testing.T) *rules.Store { + t.Helper() + dir := t.TempDir() + + writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ + Servers: []models.Server{ + {Tag: "test-1", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, + { + Tag: "test-vless", + Region: "NL", + Type: "vless", + Server: "nl.example.com", + ServerPort: 443, + UUID: "11111111-1111-1111-1111-111111111111", + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Transport: &models.Transport{Type: "ws", Path: "/ws"}, + }, + { + Tag: "test-ss", + Region: "DE", + Type: "shadowsocks", + Server: "de.example.com", + ServerPort: 8443, + Method: "2022-blake3-aes-128-gcm", + Password: "secret", + }, + }, + }) + + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{ + RuleSets: []models.RuleSet{ + {Tag: "test-rules", Description: "test", URL: "https://example.com/test.srs", Format: "binary", Type: "domain"}, + }, + }) + + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{ + Version: "0.1.0", Changelog: "test", + }) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{ + Version: "test-policy", + AlwaysDirectProcesses: []string{"chromium.exe"}, + BlockedDomains: []string{"example.com"}, + }) + writeJSON(t, filepath.Join(dir, "catalog-v2.json"), models.CatalogV2{ + Version: "2", + Nodes: []models.CatalogNode{ + { + ID: "test-vless", + Name: "Test VLESS", + Region: "NL", + Host: "1.2.3.4", + PublicHost: "nl.example.com", + Status: "healthy", + Protocols: []models.CatalogProtocol{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Auth: &models.CatalogAuth{UUID: "11111111-1111-1111-1111-111111111111"}, + Extra: map[string]any{"transport_type": "ws", "path": "/ws"}, + }, + { + Type: "vmess", + Enabled: true, + Port: 8444, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com"}, + Auth: &models.CatalogAuth{UUID: "22222222-2222-2222-2222-222222222222"}, + Extra: map[string]any{"path": "/vmess"}, + }, + { + Type: "hysteria2", + Enabled: true, + Port: 9443, + TLS: &models.TLS{Enabled: true, ServerName: "nl.example.com", Insecure: true, ALPN: []string{"h3"}, MinVersion: "1.3", MaxVersion: "1.3"}, + Auth: &models.CatalogAuth{Password: "hy2-secret"}, + Extra: map[string]any{"obfs_password": "obfs-secret"}, + }, + { + Type: "vless-reality", + Enabled: true, + Port: 443, + TLS: &models.TLS{ + Enabled: true, + ServerName: "login.microsoftonline.com", + Reality: &models.Reality{ + Enabled: true, + PublicKey: "jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0", + ShortID: "0123456789abcdef", + Fingerprint: "chrome", + }, + }, + Auth: &models.CatalogAuth{UUID: "33333333-3333-3333-3333-333333333333"}, + }, + }, + }, + }, + }) + + os.MkdirAll(filepath.Join(dir, "rules"), 0o755) + + return rules.NewStore(dir) +} + +func writeJSON(t *testing.T, path string, v any) { + t.Helper() + data, err := json.MarshalIndent(v, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } +} + +func TestServersEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/servers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.ServersResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(resp.Servers) != 3 { + t.Fatalf("expected 3 servers, got %d", len(resp.Servers)) + } + if resp.Servers[0].Tag != "test-1" { + t.Errorf("expected first tag test-1, got %s", resp.Servers[0].Tag) + } +} + +func TestSubscribeEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/subscribe", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(w.Body.String())) + if err != nil { + t.Fatalf("expected base64 response: %v", err) + } + body := string(decoded) + if !strings.Contains(body, "vless://11111111-1111-1111-1111-111111111111@nl.example.com:443?") { + t.Fatalf("expected vless link in subscription, got %q", body) + } + if !strings.Contains(body, "vmess://") { + t.Fatalf("expected vmess link in subscription, got %q", body) + } + if !strings.Contains(body, "hysteria2://hy2-secret@nl.example.com:9443/?") { + t.Fatalf("expected hysteria2 link in subscription, got %q", body) + } + if !strings.Contains(body, "insecure=1") || !strings.Contains(body, "alpn=h3") { + t.Fatalf("expected hysteria2 insecure/alpn query params in subscription, got %q", body) + } + if !strings.Contains(body, "security=reality") || !strings.Contains(body, "pbk=jNXHt1yRo0vDuchQlIP6Z0ZvjT3KtzVI-T4E7RoLJS0") { + t.Fatalf("expected reality link in subscription, got %q", body) + } + if strings.Contains(body, "socks5://1.2.3.4:1080#test-1") { + t.Fatalf("did not expect legacy-only socks link when catalog-v2 is available, got %q", body) + } +} + +func TestSubscribeEndpointPlain(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "vless://") { + t.Fatalf("expected plain subscription links, got %q", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "vmess://") { + t.Fatalf("expected vmess in plain subscription, got %q", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "hysteria2://") { + t.Fatalf("expected hysteria2 in plain subscription, got %q", w.Body.String()) + } + if !strings.Contains(w.Body.String(), "security=reality") { + t.Fatalf("expected reality in plain subscription, got %q", w.Body.String()) + } +} + +func TestSubscribeEndpointPlainLegacyFallbackPreservesTags(t *testing.T) { + dir := t.TempDir() + writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ + Servers: []models.Server{ + {Tag: "legacy-socks", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, + {Tag: "legacy-ss", Region: "NL", Type: "shadowsocks", Server: "ss.example.com", ServerPort: 8388, Method: "chacha20-ietf-poly1305", Password: "secret"}, + }, + }) + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) + + store := rules.NewStore(dir) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/subscribe?format=plain", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + body := w.Body.String() + if !strings.Contains(body, "socks5://1.2.3.4:1080#legacy-socks") { + t.Fatalf("expected legacy socks tag in subscription, got %q", body) + } + if !strings.Contains(body, "#legacy-ss") { + t.Fatalf("expected legacy shadowsocks tag in subscription, got %q", body) + } +} + +func TestRuleSetManifestEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/ruleset/manifest", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.RuleSetManifest + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(resp.RuleSets) != 1 { + t.Fatalf("expected 1 ruleset, got %d", len(resp.RuleSets)) + } +} + +func TestVersionEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/version", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.VersionResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "0.1.0" { + t.Errorf("expected version 0.1.0, got %s", resp.Version) + } +} + +func TestCatalogV2Endpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v2/catalog", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.CatalogV2 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "2" { + t.Fatalf("expected version 2, got %q", resp.Version) + } + if len(resp.Nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(resp.Nodes)) + } +} + +func TestCatalogV2EndpointFallsBackToLegacyServers(t *testing.T) { + dir := t.TempDir() + writeJSON(t, filepath.Join(dir, "servers.json"), models.ServersResponse{ + Servers: []models.Server{ + {Tag: "legacy", Region: "NL", Type: "socks", Server: "1.2.3.4", ServerPort: 1080}, + }, + }) + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) + + store := rules.NewStore(dir) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v2/catalog", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp models.CatalogV2 + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "legacy-adapter" { + t.Fatalf("expected legacy-adapter version, got %q", resp.Version) + } + if len(resp.Nodes) != 1 || resp.Nodes[0].ID != "legacy" { + t.Fatalf("unexpected fallback catalog payload: %+v", resp) + } +} + +func TestCatalogV2EndpointMissingReturns404(t *testing.T) { + dir := t.TempDir() + writeJSON(t, filepath.Join(dir, "rulesets.json"), models.RuleSetManifest{}) + writeJSON(t, filepath.Join(dir, "version.json"), models.VersionResponse{Version: "0.1.0"}) + writeJSON(t, filepath.Join(dir, "routing-policy.json"), models.RoutingPolicy{Version: "test"}) + + store := rules.NewStore(dir) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v2/catalog", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestRoutingPolicyEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/api/v1/routing-policy", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp models.RoutingPolicy + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if resp.Version != "test-policy" { + t.Fatalf("expected version test-policy, got %q", resp.Version) + } + if len(resp.AlwaysDirectProcesses) != 1 || resp.AlwaysDirectProcesses[0] != "chromium.exe" { + t.Fatalf("unexpected routing policy payload: %+v", resp) + } +} + +func TestMethodNotAllowed(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("POST", "/api/v1/servers", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code == http.StatusOK { + t.Fatal("POST should not return 200") + } +} + +func TestVPNUIEndpoint(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + req := httptest.NewRequest("GET", "/vpnui", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusTemporaryRedirect { + t.Fatalf("expected 307, got %d", w.Code) + } + if got := w.Header().Get("Location"); got != "/vpnui/" { + t.Fatalf("expected redirect to /vpnui/, got %q", got) + } + + req = httptest.NewRequest("GET", "/vpnui/", nil) + w = httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 for /vpnui/, got %d", w.Code) + } + if !strings.Contains(w.Body.String(), "Панель управления vpnem") { + t.Fatal("expected control ui html") + } +} + +func TestControlNodeUpsertAndList(t *testing.T) { + store := setupTestStore(t) + router := api.NewRouter(store) + + body := `{ + "id":"nl-01", + "name":"NL 01", + "provider":"custom-vps", + "region":"nl", + "host":"203.0.113.10", + "domain":"nl-01.example.com", + "enabled":true, + "ssh":{"user":"root","port":22,"auth":"key","identity_file":"~/.ssh/id_ed25519"}, + "protocols":[ + { + "type":"vless", + "enabled":true, + "port":443, + "tls":{"enabled":true,"server_name":"nl-01.example.com"}, + "auth":{"uuid":"11111111-1111-1111-1111-111111111111"}, + "extra":{"transport_type":"ws","path":"/ws"} + } + ] + }` + + req := httptest.NewRequest("POST", "/api/v1/control/nodes", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200 on save, got %d: %s", w.Code, w.Body.String()) + } + + listReq := httptest.NewRequest("GET", "/api/v1/control/nodes", nil) + listW := httptest.NewRecorder() + router.ServeHTTP(listW, listReq) + + if listW.Code != http.StatusOK { + t.Fatalf("expected 200 on list, got %d", listW.Code) + } + + var resp struct { + Nodes []control.Node `json:"nodes"` + States map[string]*control.NodeState `json:"states"` + } + if err := json.Unmarshal(listW.Body.Bytes(), &resp); err != nil { + t.Fatalf("invalid json: %v", err) + } + if len(resp.Nodes) != 1 { + t.Fatalf("expected 1 node, got %d", len(resp.Nodes)) + } + if resp.Nodes[0].ID != "nl-01" { + t.Fatalf("expected nl-01, got %s", resp.Nodes[0].ID) + } +} + +func TestControlCatalogPublish(t *testing.T) { + store := setupTestStore(t) + if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ + ID: "nl-01", + Name: "NL 01", + Provider: "custom-vps", + Region: "nl", + Host: "203.0.113.10", + Domain: "nl-01.example.com", + Enabled: true, + SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []control.ProtocolProfile{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-01.example.com"}, + Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, + Extra: map[string]any{"transport_type": "ws", "path": "/ws"}, + }, + }, + }); err != nil { + t.Fatal(err) + } + if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{ + NodeID: "nl-01", + BootstrapStatus: "healthy", + PublicHost: "nl-01.example.com", + }); err != nil { + t.Fatal(err) + } + + router := api.NewRouter(store) + req := httptest.NewRequest("POST", "/api/v1/control/catalog/publish", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + data, err := os.ReadFile(filepath.Join(store.DataDir(), "servers.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), `"tag": "nl-01-vless"`) { + t.Fatal("expected published vless server in servers.json") + } + catalogData, err := os.ReadFile(filepath.Join(store.DataDir(), "catalog-v2.json")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(catalogData), `"version": "2"`) { + t.Fatal("expected catalog-v2.json to be published") + } +} + +func TestDeleteControlNode(t *testing.T) { + store := setupTestStore(t) + if _, err := control.SaveNodeFile(filepath.Join(store.DataDir(), "control", "inventory"), control.Node{ + ID: "nl-delete", + Name: "Delete Node", + Provider: "custom-vps", + Region: "nl", + Host: "203.0.113.20", + Domain: "nl-delete.example.com", + Enabled: true, + SSH: control.SSHConfig{User: "root", Port: 22, Auth: "key", IdentityFile: "~/.ssh/id_ed25519"}, + Protocols: []control.ProtocolProfile{ + { + Type: "vless", + Enabled: true, + Port: 443, + TLS: &control.TLSProfile{Enabled: true, ServerName: "nl-delete.example.com"}, + Auth: &control.AuthProfile{UUID: "11111111-1111-1111-1111-111111111111"}, + }, + }, + }); err != nil { + t.Fatal(err) + } + if err := control.SaveNodeState(filepath.Join(store.DataDir(), "control", "state"), control.NodeState{ + NodeID: "nl-delete", + BootstrapStatus: "healthy", + }); err != nil { + t.Fatal(err) + } + + router := api.NewRouter(store) + req := httptest.NewRequest("DELETE", "/api/v1/control/nodes/nl-delete", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String()) + } + + if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "inventory", "nl-delete.yaml")); !os.IsNotExist(err) { + t.Fatalf("expected node file to be deleted, got err=%v", err) + } + if _, err := os.Stat(filepath.Join(store.DataDir(), "control", "state", "nl-delete.json")); !os.IsNotExist(err) { + t.Fatalf("expected node state to be deleted, got err=%v", err) + } +} diff --git a/internal/api/middleware.go b/internal/api/middleware.go new file mode 100644 index 0000000..76885ac --- /dev/null +++ b/internal/api/middleware.go @@ -0,0 +1,70 @@ +package api + +import ( + "context" + "net" + "net/http" + "strings" +) + +// contextKey for real IP. +type contextKey string + +const ctxRealIP contextKey = "real_ip" + +// RealIP middleware extracts the client's real public IP. +// Priority: X-Forwarded-For (from Traefik) > X-Real-IP > RemoteAddr. +func RealIP(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ip := extractRealIP(r) + if ip != "" { + r = r.WithContext(context.WithValue(r.Context(), ctxRealIP, ip)) + } + next(w, r) + } +} + +// GetRealIP returns the client IP from context. +func GetRealIP(r *http.Request) string { + if ip, ok := r.Context().Value(ctxRealIP).(string); ok { + return ip + } + return "" +} + +func extractRealIP(r *http.Request) string { + // 1. X-Forwarded-For (Traefik, nginx, etc.) + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + // Can contain multiple IPs: client, proxy1, proxy2 + // First one is the original client + parts := strings.Split(xff, ",") + if len(parts) > 0 { + ip := strings.TrimSpace(parts[0]) + if isValidIP(ip) { + return ip + } + } + } + + // 2. X-Real-IP (some proxies use this) + if xri := r.Header.Get("X-Real-IP"); xri != "" { + ip := strings.TrimSpace(xri) + if isValidIP(ip) { + return ip + } + } + + // 3. RemoteAddr fallback (direct connection) + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err == nil && isValidIP(host) { + return host + } + + return "" +} + +func isValidIP(ip string) bool { + // Accept both IPv4 and IPv6 + parsed := net.ParseIP(ip) + return parsed != nil +} diff --git a/internal/api/recommend_test.go b/internal/api/recommend_test.go new file mode 100644 index 0000000..8449db0 --- /dev/null +++ b/internal/api/recommend_test.go @@ -0,0 +1,549 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "vpnem/internal/models" + "vpnem/internal/rules" +) + +func TestRealIPMiddleware(t *testing.T) { + tests := []struct { + name string + headers map[string]string + remote string + wantIP string + }{ + { + name: "X-Forwarded-For single IP", + headers: map[string]string{"X-Forwarded-For": "1.2.3.4"}, + remote: "10.0.0.1:1234", + wantIP: "1.2.3.4", + }, + { + name: "X-Forwarded-For multiple proxies", + headers: map[string]string{"X-Forwarded-For": "91.234.56.78, 10.0.0.1, 172.16.0.1"}, + remote: "10.0.0.1:1234", + wantIP: "91.234.56.78", + }, + { + name: "X-Real-IP fallback", + headers: map[string]string{"X-Real-IP": "5.6.7.8"}, + remote: "10.0.0.1:1234", + wantIP: "5.6.7.8", + }, + { + name: "RemoteAddr fallback", + headers: map[string]string{}, + remote: "91.234.56.78:54321", + wantIP: "91.234.56.78", + }, + { + name: "XFF takes priority over X-Real-IP", + headers: map[string]string{"X-Forwarded-For": "1.1.1.1", "X-Real-IP": "2.2.2.2"}, + remote: "10.0.0.1:1234", + wantIP: "1.1.1.1", + }, + { + name: "XFF takes priority over RemoteAddr", + headers: map[string]string{"X-Forwarded-For": "3.3.3.3"}, + remote: "4.4.4.4:8080", + wantIP: "3.3.3.3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.RemoteAddr = tt.remote + for k, v := range tt.headers { + req.Header.Set(k, v) + } + + handler := RealIP(func(w http.ResponseWriter, r *http.Request) { + ip := GetRealIP(r) + if ip != tt.wantIP { + t.Errorf("GetRealIP() = %q, want %q", ip, tt.wantIP) + } + }) + + rec := httptest.NewRecorder() + handler(rec, req) + }) + } +} + +func TestRealIPMiddlewareIPv6(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/test", nil) + req.Header.Set("X-Forwarded-For", "2001:db8::1") + req.RemoteAddr = "[::1]:1234" + + handler := RealIP(func(w http.ResponseWriter, r *http.Request) { + ip := GetRealIP(r) + if ip != "2001:db8::1" { + t.Errorf("GetRealIP() = %q, want 2001:db8::1", ip) + } + }) + + rec := httptest.NewRecorder() + handler(rec, req) +} + +func TestClientConnectEndpoint(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // Request with X-Forwarded-For to simulate Traefik + body := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } + + var resp models.RecommendationResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + + // First client — load-balanced recommendation (all servers have 0 load) + if resp.RecommendedServerIP == "" { + t.Error("expected non-empty recommendation") + } + if resp.Reason == "" { + t.Error("expected non-empty reason") + } +} + +func TestClientConnectMissingServerIP(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + body := `{"node_id":"nl-198"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest) + } +} + +func TestClientConnectNoClientIP(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + body := `{"server_ip":"5.180.97.198"}` + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + // No X-Forwarded-For, no X-Real-IP — but RemoteAddr should still work + + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + + // Should succeed using RemoteAddr + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", rec.Code, http.StatusOK, rec.Body.String()) + } +} + +func TestClientDisconnectEndpoint(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // First connect + connBody := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows","version":"2.0.11"}` + connReq := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(connBody)) + connReq.Header.Set("Content-Type", "application/json") + connReq.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, connReq) + + if rec1.Code != http.StatusOK { + t.Fatalf("connect status = %d", rec1.Code) + } + + // Verify session exists + load := store.Connections().GetLoadInfo([]string{"5.180.97.198"}) + if len(load) == 0 || load[0].ActiveClients != 1 { + t.Fatalf("expected 1 active client after connect, got %v", load) + } + + // Disconnect + discBody := `{"server_ip":"5.180.97.198","node_id":"nl-198"}` + discReq := httptest.NewRequest(http.MethodPost, "/api/v1/disconnect", strings.NewReader(discBody)) + discReq.Header.Set("Content-Type", "application/json") + discReq.Header.Set("X-Forwarded-For", "91.234.56.78") + + rec2 := httptest.NewRecorder() + RealIP(handler.ClientDisconnect)(rec2, discReq) + + if rec2.Code != http.StatusOK { + t.Fatalf("disconnect status = %d, want %d", rec2.Code, http.StatusOK) + } + + // Verify session removed + load = store.Connections().GetLoadInfo([]string{"5.180.97.198"}) + if len(load) == 0 || load[0].ActiveClients != 0 { + t.Fatalf("expected 0 active clients after disconnect, got %v", load) + } +} + +func TestClientDisconnectEmptyBody(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // First connect + connBody := `{"server_ip":"5.180.97.198","node_id":"nl-198"}` + connReq := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(connBody)) + connReq.Header.Set("Content-Type", "application/json") + connReq.Header.Set("X-Forwarded-For", "10.20.30.40") + + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, connReq) + if rec1.Code != http.StatusOK { + t.Fatalf("connect status = %d", rec1.Code) + } + + // Disconnect with empty body — should still work using client IP from header + discReq := httptest.NewRequest(http.MethodPost, "/api/v1/disconnect", strings.NewReader("")) + discReq.Header.Set("Content-Type", "application/json") + discReq.Header.Set("X-Forwarded-For", "10.20.30.40") + + rec2 := httptest.NewRecorder() + RealIP(handler.ClientDisconnect)(rec2, discReq) + + if rec2.Code != http.StatusOK { + t.Fatalf("disconnect status = %d, want %d", rec2.Code, http.StatusOK) + } + + // Verify session removed + load := store.Connections().GetLoadInfo([]string{"5.180.97.198"}) + if len(load) > 0 && load[0].ActiveClients != 0 { + t.Fatalf("expected 0 active clients, got %v", load) + } +} + +func TestRecommendEndpoint(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // Studio 1 connects to 198 + conn1 := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"windows"}` + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(conn1)) + req1.Header.Set("Content-Type", "application/json") + req1.Header.Set("X-Forwarded-For", "1.1.1.1") + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, req1) + + // Studio 2 connects to 198 + conn2 := `{"server_ip":"5.180.97.198","node_id":"nl-198","os":"linux"}` + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", strings.NewReader(conn2)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("X-Forwarded-For", "2.2.2.2") + rec2 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec2, req2) + + // New studio asks for recommendation — should get least loaded + req3 := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req3.Header.Set("X-Forwarded-For", "3.3.3.3") + rec3 := httptest.NewRecorder() + RealIP(handler.Recommend)(rec3, req3) + + if rec3.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec3.Code, http.StatusOK) + } + + var resp models.RecommendationResponse + if err := json.Unmarshal(rec3.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + // Both 198 has 2 clients, 197 and 199 have 0 — should pick one of them + if resp.RecommendedServerIP == "5.180.97.198" { + t.Errorf("should not recommend loaded server, got %s", resp.RecommendedServerIP) + } + if resp.RecommendedServerIP == "" { + t.Error("expected recommendation") + } +} + +func TestRecommendNoClientIP(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + // No X-Forwarded-For — but RemoteAddr fallback should still work + req.RemoteAddr = "10.0.0.1:54321" + + rec := httptest.NewRecorder() + RealIP(handler.Recommend)(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } +} + +func TestConnectRecommendFlowMultipleStudios(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + studios := []struct { + ip string + serverIP string + }{ + {"11.22.33.44", "5.180.97.198"}, + {"55.66.77.88", "5.180.97.198"}, + {"99.10.11.12", "5.180.97.199"}, + } + + for _, s := range studios { + body, _ := json.Marshal(map[string]string{ + "server_ip": s.serverIP, + "node_id": "nl-x", + "os": "windows", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", s.ip) + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("connect for %s: status = %d", s.ip, rec.Code) + } + } + + // Load info: 198=2, 199=1, 197=0 + load := store.Connections().GetLoadInfo([]string{"5.180.97.198", "5.180.97.199", "5.180.97.197"}) + + expectedLoad := map[string]int{ + "5.180.97.198": 2, + "5.180.97.199": 1, + "5.180.97.197": 0, + } + + for _, info := range load { + want := expectedLoad[info.ServerIP] + if info.ActiveClients != want { + t.Errorf("%s: active = %d, want %d", info.ServerIP, info.ActiveClients, want) + } + } + + // New studio should get 197 (least loaded) + req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req.Header.Set("X-Forwarded-For", "123.123.123.123") + rec := httptest.NewRecorder() + RealIP(handler.Recommend)(rec, req) + + var resp models.RecommendationResponse + json.Unmarshal(rec.Body.Bytes(), &resp) + + // New studio should get a server with 0 clients (197 or 181 — both have 0) + if resp.RecommendedServerIP == "5.180.97.198" || resp.RecommendedServerIP == "5.180.97.199" { + t.Errorf("new studio should get unloaded server, got %s", resp.RecommendedServerIP) + } +} + +func TestRebalancingTriggersOnOverload(t *testing.T) { + store := setupTestStore(t) + store.Connections().SetMaxCapacity(2) // tiny capacity + handler := NewHandler(store) + + // 2 studios connect to 198 (100% load) + for i := 0; i < 2; i++ { + ip := "10.0.0." + string(rune('0'+i+1)) + "1" + body, _ := json.Marshal(map[string]string{ + "server_ip": "5.180.97.198", + "node_id": "nl-198", + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Forwarded-For", ip) + rec := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec, req) + } + + // Studio 1 (home=198, load=100%) asks for recommendation + // 199 has 0% — should rebalance + req := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req.Header.Set("X-Forwarded-For", "10.0.0.11") + rec := httptest.NewRecorder() + RealIP(handler.Recommend)(rec, req) + + var resp models.RecommendationResponse + json.Unmarshal(rec.Body.Bytes(), &resp) + + if !resp.IsRebalance { + t.Logf("note: rebalancing did not trigger (home stickiness may win with tiny sample)") + } + t.Logf("rebalance test: recommended=%s, isRebalance=%v, reason=%s, loadInfo=%s", + resp.RecommendedServerIP, resp.IsRebalance, resp.Reason, resp.LoadInfo) +} + +func TestHealthyServerFilter(t *testing.T) { + store := setupTestStore(t) + handler := &Handler{store: store} + + // Override the healthy check for this test — we test getHealthyServerIPs directly + // For now, all available IPs are healthy. Just verify it returns the right set. + healthy := handler.getHealthyServerIPs() + + // With servers.json containing 5.180.97.200, 5.180.97.199, 5.180.97.198, 5.180.97.197, 5.180.97.181 + // and 84.252.100.x (RU servers) + if len(healthy) == 0 { + t.Error("expected some healthy servers") + } +} + +func TestGetAvailableServerIPs(t *testing.T) { + store := setupTestStore(t) + handler := &Handler{store: store} + + ips := handler.getAvailableServerIPs() + + // Test store has 3 MULTI nodes (198, 199, 197) and 1 SOCKS5-only node (181). + // Only MULTI IPs should be returned for recommendation. + if len(ips) != 3 { + t.Fatalf("expected 3 MULTI IPs, got %d: %v", len(ips), ips) + } + + // SOCKS5-only IP should NOT be in the list + for _, ip := range ips { + if ip == "5.180.97.181" { + t.Error("SOCKS5-only IP 5.180.97.181 should not be recommended") + } + } + + // MULTI IPs should be present + expected := map[string]bool{"5.180.97.198": true, "5.180.97.199": true, "5.180.97.197": true} + for _, ip := range ips { + if !expected[ip] { + t.Errorf("unexpected IP: %s", ip) + } + } +} + +func TestLoadInfoInResponse(t *testing.T) { + store := setupTestStore(t) + handler := NewHandler(store) + + // Connect some clients + body1, _ := json.Marshal(map[string]string{"server_ip": "5.180.97.198", "node_id": "nl-198"}) + req1 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body1)) + req1.Header.Set("Content-Type", "application/json") + req1.Header.Set("X-Forwarded-For", "1.1.1.1") + rec1 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec1, req1) + + body2, _ := json.Marshal(map[string]string{"server_ip": "5.180.97.198", "node_id": "nl-198"}) + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/connect", bytes.NewReader(body2)) + req2.Header.Set("Content-Type", "application/json") + req2.Header.Set("X-Forwarded-For", "2.2.2.2") + rec2 := httptest.NewRecorder() + RealIP(handler.ClientConnect)(rec2, req2) + + // Ask for recommendation — should include load info + req3 := httptest.NewRequest(http.MethodGet, "/api/v1/recommend", nil) + req3.Header.Set("X-Forwarded-For", "3.3.3.3") + rec3 := httptest.NewRecorder() + RealIP(handler.Recommend)(rec3, req3) + + var resp models.RecommendationResponse + json.Unmarshal(rec3.Body.Bytes(), &resp) + + if resp.LoadInfo == "" { + t.Error("expected load_info in response") + } + if !strings.Contains(resp.LoadInfo, "нагрузка") { + t.Errorf("load_info should contain russian text, got: %s", resp.LoadInfo) + } + t.Logf("Load info: %s", resp.LoadInfo) +} + +func setupTestStore(t *testing.T) *rules.Store { + t.Helper() + dir := t.TempDir() + + writeJSON := func(name string, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, name), data, 0o600); err != nil { + t.Fatal(err) + } + } + + // Create catalog-v2.json with MULTI nodes so recommendation works + writeJSON("catalog-v2.json", map[string]any{ + "version": "2", + "nodes": []map[string]any{ + { + "id": "nl-multi-198", + "name": "NL-MULTI 198", + "region": "nl", + "host": "5.180.97.198", + "public_host": "5.180.97.198", + "protocols": []map[string]any{ + {"type": "vless-reality", "enabled": true, "port": 443}, + {"type": "hysteria2", "enabled": true, "port": 443}, + {"type": "socks5", "enabled": true, "port": 54101}, + }, + }, + { + "id": "nl-multi-199", + "name": "NL-MULTI 199", + "region": "nl", + "host": "5.180.97.199", + "public_host": "5.180.97.199", + "protocols": []map[string]any{ + {"type": "vless-reality", "enabled": true, "port": 443}, + {"type": "hysteria2", "enabled": true, "port": 443}, + }, + }, + { + "id": "nl-multi-197", + "name": "NL-MULTI 197", + "region": "nl", + "host": "5.180.97.197", + "public_host": "5.180.97.197", + "protocols": []map[string]any{ + {"type": "vless-reality", "enabled": true, "port": 443}, + {"type": "hysteria2", "enabled": true, "port": 443}, + }, + }, + { + "id": "nl-socks5-181", + "name": "NL-SOCKS5 181", + "region": "nl", + "host": "5.180.97.181", + "public_host": "5.180.97.181", + "protocols": []map[string]any{ + {"type": "socks5", "enabled": true, "port": 54101}, + }, + }, + }, + }) + writeJSON("rulesets.json", models.RuleSetManifest{RuleSets: []models.RuleSet{}}) + writeJSON("version.json", models.VersionResponse{Version: "test"}) + writeJSON("routing-policy.json", models.RoutingPolicy{Version: "test"}) + + return rules.NewStore(dir) +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..1cb9ff6 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,80 @@ +package api + +import ( + "net/http" + + "vpnem/internal/rules" +) + +func NewRouter(store *rules.Store) http.Handler { + h := NewHandler(store) + mux := http.NewServeMux() + + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"ok"}`)) + }) + mux.HandleFunc("/vpnui", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + http.Redirect(w, r, "/vpnui/", http.StatusTemporaryRedirect) + }) + mux.HandleFunc("/vpnui/", methodHandler(http.MethodGet, h.VPNUI)) + mux.HandleFunc("/api/v1/servers", methodHandler(http.MethodGet, h.Servers)) + mux.HandleFunc("/api/v2/catalog", methodHandler(http.MethodGet, h.CatalogV2)) + mux.HandleFunc("/api/v1/routing-policy", methodHandler(http.MethodGet, h.RoutingPolicy)) + mux.HandleFunc("/api/v1/subscribe", methodHandler(http.MethodGet, h.Subscribe)) + mux.HandleFunc("/api/v1/ruleset/manifest", methodHandler(http.MethodGet, h.RuleSetManifest)) + mux.HandleFunc("/api/v1/version", methodHandler(http.MethodGet, h.Version)) + mux.HandleFunc("/api/v1/control/nodes", func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + h.ControlNodes(w, r) + case http.MethodPost: + h.UpsertControlNode(w, r) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + mux.HandleFunc("/api/v1/control/preflight", methodHandler(http.MethodPost, h.QuickPreflightControlNode)) + mux.HandleFunc("/api/v1/control/quick-provision", methodHandler(http.MethodPost, h.QuickProvisionControlNode)) + mux.HandleFunc("/api/v1/control/nodes/", h.ControlNodeAction) + mux.HandleFunc("/api/v1/control/catalog/publish", methodHandler(http.MethodPost, h.PublishControlCatalog)) + + // Static file serving for .srs and .txt rule files + rulesFS := http.StripPrefix("/rules/", http.FileServer(http.Dir(store.RulesDir()))) + mux.Handle("/rules/", rulesFS) + + // Static file serving for client releases + releasesFS := http.StripPrefix("/releases/", http.FileServer(http.Dir(store.ReleasesDir()))) + mux.Handle("/releases/", releasesFS) + + // Client error log endpoint (obscure URL, no auth needed — just writes to file) + mux.HandleFunc("/logs2026vpnem/errors", methodHandler(http.MethodPost, h.ClientLog)) + + // Web viewer for client logs (admin-protected via env var) + mux.HandleFunc("/client-logs", methodHandler(http.MethodGet, h.ClientLogsViewer)) + + // Client connection report and recommendation (RealIP middleware auto-detects client IP) + mux.HandleFunc("/api/v1/connect", RealIP(h.ClientConnect)) + mux.HandleFunc("/api/v1/disconnect", RealIP(h.ClientDisconnect)) + mux.HandleFunc("/api/v1/recommend", RealIP(h.Recommend)) + + return mux +} + +func methodHandler(method string, next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != method { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + next(w, r) + } +} diff --git a/internal/api/subscribe.go b/internal/api/subscribe.go new file mode 100644 index 0000000..b4890fa --- /dev/null +++ b/internal/api/subscribe.go @@ -0,0 +1,288 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "vpnem/internal/models" +) + +func (h *Handler) Subscribe(w http.ResponseWriter, r *http.Request) { + links := make([]string, 0) + + catalog, err := h.store.LoadCatalogV2OrLegacy() + if err == nil { + for _, node := range catalog.Nodes { + for _, protocol := range node.Protocols { + link, ok := subscriptionLinkV2(node, protocol) + if !ok { + continue + } + links = append(links, link) + } + } + } else { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + if r.URL.Query().Get("format") == "plain" { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(strings.Join(links, "\n"))) + return + } + + payload := base64.StdEncoding.EncodeToString([]byte(strings.Join(links, "\n"))) + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte(payload)) +} + +func subscriptionLink(server models.Server) (string, bool) { + switch server.Type { + case "vless": + if strings.TrimSpace(server.UUID) == "" { + return "", false + } + query := url.Values{} + security := "none" + if server.TLS != nil && server.TLS.Enabled { + security = "tls" + if strings.TrimSpace(server.TLS.ServerName) != "" { + query.Set("sni", server.TLS.ServerName) + } + } + query.Set("security", security) + if server.Transport != nil { + if strings.TrimSpace(server.Transport.Type) != "" { + query.Set("type", server.Transport.Type) + } + if strings.TrimSpace(server.Transport.Path) != "" { + query.Set("path", server.Transport.Path) + } + } + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + server.UUID, + server.Server, + server.ServerPort, + query.Encode(), + url.QueryEscape(server.Tag), + ), true + case "vless-reality": + if strings.TrimSpace(server.UUID) == "" || server.TLS == nil || server.TLS.Reality == nil { + return "", false + } + query := url.Values{} + query.Set("encryption", "none") + query.Set("security", "reality") + query.Set("sni", server.TLS.ServerName) + query.Set("fp", firstNonEmpty(server.TLS.Reality.Fingerprint, "chrome")) + query.Set("pbk", server.TLS.Reality.PublicKey) + query.Set("sid", server.TLS.Reality.ShortID) + query.Set("type", "tcp") + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + server.UUID, + server.Server, + server.ServerPort, + query.Encode(), + url.QueryEscape(server.Tag), + ), true + case "shadowsocks": + if strings.TrimSpace(server.Method) == "" || strings.TrimSpace(server.Password) == "" { + return "", false + } + userInfo := base64.StdEncoding.EncodeToString([]byte(server.Method + ":" + server.Password)) + return fmt.Sprintf( + "ss://%s@%s:%d#%s", + userInfo, + server.Server, + server.ServerPort, + url.QueryEscape(server.Tag), + ), true + case "socks": + return fmt.Sprintf( + "socks5://%s:%d#%s", + server.Server, + server.ServerPort, + url.QueryEscape(server.Tag), + ), true + default: + return "", false + } +} + +func subscriptionLinkV2(node models.CatalogNode, protocol models.CatalogProtocol) (string, bool) { + host := node.PublicHost + if strings.TrimSpace(host) == "" { + if strings.TrimSpace(node.Domain) != "" { + host = node.Domain + } else { + host = node.Host + } + } + tag := subscriptionTag(node, protocol) + + switch protocol.Type { + case "vless": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return "", false + } + query := url.Values{} + security := "none" + if protocol.TLS != nil && protocol.TLS.Enabled { + security = "tls" + if strings.TrimSpace(protocol.TLS.ServerName) != "" { + query.Set("sni", protocol.TLS.ServerName) + } + } + query.Set("security", security) + if transportType, _ := protocol.Extra["transport_type"].(string); transportType != "" { + query.Set("type", transportType) + } + if path, _ := protocol.Extra["path"].(string); path != "" { + query.Set("path", path) + } + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + protocol.Auth.UUID, + host, + protocol.Port, + query.Encode(), + url.QueryEscape(tag), + ), true + case "vless-reality": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" || protocol.TLS == nil || protocol.TLS.Reality == nil { + return "", false + } + query := url.Values{} + query.Set("encryption", "none") + query.Set("security", "reality") + query.Set("sni", protocol.TLS.ServerName) + query.Set("fp", firstNonEmpty(protocol.TLS.Reality.Fingerprint, "chrome")) + query.Set("pbk", protocol.TLS.Reality.PublicKey) + query.Set("sid", protocol.TLS.Reality.ShortID) + query.Set("type", "tcp") + return fmt.Sprintf( + "vless://%s@%s:%d?%s#%s", + protocol.Auth.UUID, + host, + protocol.Port, + query.Encode(), + url.QueryEscape(tag), + ), true + case "shadowsocks": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Method) == "" || strings.TrimSpace(protocol.Auth.Password) == "" { + return "", false + } + userInfo := base64.StdEncoding.EncodeToString([]byte(protocol.Auth.Method + ":" + protocol.Auth.Password)) + return fmt.Sprintf( + "ss://%s@%s:%d#%s", + userInfo, + host, + protocol.Port, + url.QueryEscape(tag), + ), true + case "socks", "socks5": + return fmt.Sprintf( + "socks5://%s:%d#%s", + host, + protocol.Port, + url.QueryEscape(tag), + ), true + case "vmess": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.UUID) == "" { + return "", false + } + payload := map[string]string{ + "v": "2", + "ps": tag, + "add": host, + "port": fmt.Sprintf("%d", protocol.Port), + "id": protocol.Auth.UUID, + "aid": "0", + "scy": "auto", + "net": "ws", + "type": "none", + "host": strings.TrimSpace(protocol.TLS.ServerName), + "path": stringFromExtraMap(protocol.Extra, "path", "/vmess"), + "tls": vmessTLSValue(protocol.TLS), + "sni": strings.TrimSpace(protocol.TLS.ServerName), + } + if payload["host"] == "" { + payload["host"] = host + } + if payload["sni"] == "" { + payload["sni"] = host + } + data, err := json.Marshal(payload) + if err != nil { + return "", false + } + return "vmess://" + base64.StdEncoding.EncodeToString(data), true + case "hysteria2": + if protocol.Auth == nil || strings.TrimSpace(protocol.Auth.Password) == "" { + return "", false + } + query := url.Values{} + sni := "" + if protocol.TLS != nil && strings.TrimSpace(protocol.TLS.ServerName) != "" { + sni = protocol.TLS.ServerName + } + if sni != "" { + query.Set("sni", sni) + } + query.Set("alpn", "h3") + query.Set("insecure", "1") + if obfsPassword, _ := protocol.Extra["obfs_password"].(string); obfsPassword != "" { + query.Set("obfs", "salamander") + query.Set("obfs-password", obfsPassword) + } + return fmt.Sprintf( + "hysteria2://%s@%s:%d/?%s#%s", + url.QueryEscape(protocol.Auth.Password), + host, + protocol.Port, + query.Encode(), + url.QueryEscape(tag), + ), true + default: + return "", false + } +} + +func subscriptionTag(node models.CatalogNode, protocol models.CatalogProtocol) string { + if legacy := stringFromExtraMap(protocol.Extra, "legacy_tag", ""); legacy != "" { + return legacy + } + return node.ID + "-" + protocol.Type +} + +func stringFromExtraMap(extra map[string]any, key, fallback string) string { + if extra == nil { + return fallback + } + value, _ := extra[key].(string) + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func vmessTLSValue(tls *models.TLS) string { + if tls != nil && tls.Enabled { + return "tls" + } + return "" +} + +func firstNonEmpty(value, fallback string) string { + if strings.TrimSpace(value) != "" { + return value + } + return fallback +} -- cgit v1.2.3