summaryrefslogtreecommitdiff
path: root/internal/api/handlers.go
diff options
context:
space:
mode:
authorsergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
committersergei <sergei@em-sysadmin.xyz>2026-04-14 06:23:55 +0400
commit3d51aa455006903345f554a2dd90034993796114 (patch)
tree62a7be2faf047f5eb7886feebc3b815556f03d7f /internal/api/handlers.go
downloadvpnem-3d51aa455006903345f554a2dd90034993796114.tar.gz
vpnem-3d51aa455006903345f554a2dd90034993796114.tar.bz2
vpnem-3d51aa455006903345f554a2dd90034993796114.zip
vpnem: VPN infrastructure with load-balanced multi-protocol nodesHEADmain
- 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
Diffstat (limited to 'internal/api/handlers.go')
-rw-r--r--internal/api/handlers.go345
1 files changed, 345 insertions, 0 deletions
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(`<tr><td><a href="/client-logs?file=%s">%s</a></td><td>%s</td><td>%d B</td></tr>`,
+ e.Name(), e.Name(), info.ModTime().Format("2006-01-02 15:04"), size)
+ }
+
+ html := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Client Error Logs</title>
+<style>
+body { font-family: system-ui, sans-serif; max-width: 900px; margin: 2rem auto; padding: 0 1rem; background: #f9fafb; }
+h1 { font-size: 1.4rem; }
+table { width: 100%%; border-collapse: collapse; background: #fff; border-radius: 8px; overflow: hidden; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
+th { background: #111827; color: #fff; text-align: left; padding: 0.6rem 1rem; }
+td { padding: 0.5rem 1rem; border-top: 1px solid #e5e7eb; }
+tr:hover td { background: #f3f4f6; }
+a { color: #2563eb; text-decoration: none; }
+a:hover { text-decoration: underline; }
+.empty { padding: 2rem; text-align: center; color: #6b7280; }
+</style></head><body>
+<h1>📋 Client Error Logs</h1>
+<p>Files from vpnem clients that reported errors.</p>
+%s
+</body></html>`, func() string {
+ if rows == "" {
+ return `<div class="empty">No client error logs yet.</div>`
+ }
+ return `<table><tr><th>File</th><th>Modified</th><th>Size</th></tr>` + rows + `</table>`
+ }())
+
+ 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
+}