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 `| File | Modified | Size |
` + rows + `
`
}())
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
}