// Package management provides the management API handlers and middleware // for configuring the server and managing auth files. package management import ( "crypto/subtle" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "github.com/gin-gonic/gin" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" "golang.org/x/crypto/bcrypt" ) type attemptInfo struct { count int blockedUntil time.Time } // Handler aggregates config reference, persistence path and helpers. type Handler struct { cfg *config.Config configFilePath string mu sync.Mutex attemptsMu sync.Mutex failedAttempts map[string]*attemptInfo // keyed by client IP authManager *coreauth.Manager usageStats *usage.RequestStatistics tokenStore coreauth.Store localPassword string allowRemoteOverride bool envSecret string logDir string } // NewHandler creates a new management handler instance. func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler { envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD") envSecret = strings.TrimSpace(envSecret) return &Handler{ cfg: cfg, configFilePath: configFilePath, failedAttempts: make(map[string]*attemptInfo), authManager: manager, usageStats: usage.GetRequestStatistics(), tokenStore: sdkAuth.GetTokenStore(), allowRemoteOverride: envSecret != "", envSecret: envSecret, } } // SetConfig updates the in-memory config reference when the server hot-reloads. func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg } // SetAuthManager updates the auth manager reference used by management endpoints. func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager } // SetUsageStatistics allows replacing the usage statistics reference. func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats } // SetLocalPassword configures the runtime-local password accepted for localhost requests. func (h *Handler) SetLocalPassword(password string) { h.localPassword = password } // SetLogDirectory updates the directory where main.log should be looked up. func (h *Handler) SetLogDirectory(dir string) { if dir == "" { return } if !filepath.IsAbs(dir) { if abs, err := filepath.Abs(dir); err == nil { dir = abs } } h.logDir = dir } // Middleware enforces access control for management endpoints. // All requests (local and remote) require a valid management key. // Additionally, remote access requires allow-remote-management=true. func (h *Handler) Middleware() gin.HandlerFunc { const maxFailures = 5 const banDuration = 30 * time.Minute return func(c *gin.Context) { clientIP := c.ClientIP() localClient := clientIP == "127.0.0.1" || clientIP == "::1" cfg := h.cfg var ( allowRemote bool secretHash string ) if cfg != nil { allowRemote = cfg.RemoteManagement.AllowRemote secretHash = cfg.RemoteManagement.SecretKey } if h.allowRemoteOverride { allowRemote = true } envSecret := h.envSecret fail := func() {} if !localClient { h.attemptsMu.Lock() ai := h.failedAttempts[clientIP] if ai != nil { if !ai.blockedUntil.IsZero() { if time.Now().Before(ai.blockedUntil) { remaining := time.Until(ai.blockedUntil).Round(time.Second) h.attemptsMu.Unlock() c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)}) return } // Ban expired, reset state ai.blockedUntil = time.Time{} ai.count = 0 } } h.attemptsMu.Unlock() if !allowRemote { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management disabled"}) return } fail = func() { h.attemptsMu.Lock() aip := h.failedAttempts[clientIP] if aip == nil { aip = &attemptInfo{} h.failedAttempts[clientIP] = aip } aip.count++ if aip.count >= maxFailures { aip.blockedUntil = time.Now().Add(banDuration) aip.count = 0 } h.attemptsMu.Unlock() } } if secretHash == "" && envSecret == "" { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "remote management key not set"}) return } // Accept either Authorization: Bearer or X-Management-Key var provided string if ah := c.GetHeader("Authorization"); ah != "" { parts := strings.SplitN(ah, " ", 2) if len(parts) == 2 && strings.ToLower(parts[0]) == "bearer" { provided = parts[1] } else { provided = ah } } if provided == "" { provided = c.GetHeader("X-Management-Key") } if provided == "" { if !localClient { fail() } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"}) return } if localClient { if lp := h.localPassword; lp != "" { if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 { c.Next() return } } } if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 { if !localClient { h.attemptsMu.Lock() if ai := h.failedAttempts[clientIP]; ai != nil { ai.count = 0 ai.blockedUntil = time.Time{} } h.attemptsMu.Unlock() } c.Next() return } if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil { if !localClient { fail() } c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"}) return } if !localClient { h.attemptsMu.Lock() if ai := h.failedAttempts[clientIP]; ai != nil { ai.count = 0 ai.blockedUntil = time.Time{} } h.attemptsMu.Unlock() } c.Next() } } // persist saves the current in-memory config to disk. func (h *Handler) persist(c *gin.Context) bool { h.mu.Lock() defer h.mu.Unlock() // Preserve comments when writing if err := config.SaveConfigPreserveComments(h.configFilePath, h.cfg); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to save config: %v", err)}) return false } c.JSON(http.StatusOK, gin.H{"status": "ok"}) return true } // Helper methods for simple types func (h *Handler) updateBoolField(c *gin.Context, set func(bool)) { var body struct { Value *bool `json:"value"` } if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { var m map[string]any if err2 := c.ShouldBindJSON(&m); err2 == nil { for _, v := range m { if b, ok := v.(bool); ok { set(b) h.persist(c) return } } } c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } set(*body.Value) h.persist(c) } func (h *Handler) updateIntField(c *gin.Context, set func(int)) { var body struct { Value *int `json:"value"` } if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } set(*body.Value) h.persist(c) } func (h *Handler) updateStringField(c *gin.Context, set func(string)) { var body struct { Value *string `json:"value"` } if err := c.ShouldBindJSON(&body); err != nil || body.Value == nil { c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) return } set(*body.Value) h.persist(c) }