| |
| |
| 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/buildinfo" |
| "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 |
| lastActivity time.Time |
| } |
|
|
| |
| const attemptCleanupInterval = 1 * time.Hour |
|
|
| |
| const attemptMaxIdleTime = 2 * time.Hour |
|
|
| |
| type Handler struct { |
| cfg *config.Config |
| configFilePath string |
| mu sync.Mutex |
| attemptsMu sync.Mutex |
| failedAttempts map[string]*attemptInfo |
| authManager *coreauth.Manager |
| usageStats *usage.RequestStatistics |
| tokenStore coreauth.Store |
| localPassword string |
| allowRemoteOverride bool |
| envSecret string |
| logDir string |
| } |
|
|
| |
| func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler { |
| envSecret, _ := os.LookupEnv("MANAGEMENT_PASSWORD") |
| envSecret = strings.TrimSpace(envSecret) |
|
|
| h := &Handler{ |
| cfg: cfg, |
| configFilePath: configFilePath, |
| failedAttempts: make(map[string]*attemptInfo), |
| authManager: manager, |
| usageStats: usage.GetRequestStatistics(), |
| tokenStore: sdkAuth.GetTokenStore(), |
| allowRemoteOverride: envSecret != "", |
| envSecret: envSecret, |
| } |
| h.startAttemptCleanup() |
| return h |
| } |
|
|
| |
| |
| func (h *Handler) startAttemptCleanup() { |
| go func() { |
| ticker := time.NewTicker(attemptCleanupInterval) |
| defer ticker.Stop() |
| for range ticker.C { |
| h.purgeStaleAttempts() |
| } |
| }() |
| } |
|
|
| |
| |
| func (h *Handler) purgeStaleAttempts() { |
| now := time.Now() |
| h.attemptsMu.Lock() |
| defer h.attemptsMu.Unlock() |
| for ip, ai := range h.failedAttempts { |
| |
| if !ai.blockedUntil.IsZero() && now.Before(ai.blockedUntil) { |
| continue |
| } |
| |
| if now.Sub(ai.lastActivity) > attemptMaxIdleTime { |
| delete(h.failedAttempts, ip) |
| } |
| } |
| } |
|
|
| |
| func NewHandlerWithoutConfigFilePath(cfg *config.Config, manager *coreauth.Manager) *Handler { |
| return NewHandler(cfg, "", manager) |
| } |
|
|
| |
| func (h *Handler) SetConfig(cfg *config.Config) { h.cfg = cfg } |
|
|
| |
| func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = manager } |
|
|
| |
| func (h *Handler) SetUsageStatistics(stats *usage.RequestStatistics) { h.usageStats = stats } |
|
|
| |
| func (h *Handler) SetLocalPassword(password string) { h.localPassword = password } |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
| func (h *Handler) Middleware() gin.HandlerFunc { |
| const maxFailures = 5 |
| const banDuration = 30 * time.Minute |
|
|
| return func(c *gin.Context) { |
| c.Header("X-CPA-VERSION", buildinfo.Version) |
| c.Header("X-CPA-COMMIT", buildinfo.Commit) |
| c.Header("X-CPA-BUILD-DATE", buildinfo.BuildDate) |
|
|
| 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 |
| } |
| |
| 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++ |
| aip.lastActivity = time.Now() |
| 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 |
| } |
|
|
| |
| 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() |
| } |
| } |
|
|
| |
| func (h *Handler) persist(c *gin.Context) bool { |
| h.mu.Lock() |
| defer h.mu.Unlock() |
| |
| 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 |
| } |
|
|
| |
| 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 { |
| 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) |
| } |
|
|