diff options
Diffstat (limited to 'internal/api/handlers.go')
| -rw-r--r-- | internal/api/handlers.go | 345 |
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 +} |
