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 }