| | |
| | |
| | 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 |
| | } |
| |
|
| | |
| | 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) |
| |
|
| | return &Handler{ |
| | cfg: cfg, |
| | configFilePath: configFilePath, |
| | failedAttempts: make(map[string]*attemptInfo), |
| | authManager: manager, |
| | usageStats: usage.GetRequestStatistics(), |
| | tokenStore: sdkAuth.GetTokenStore(), |
| | allowRemoteOverride: envSecret != "", |
| | envSecret: envSecret, |
| | } |
| | } |
| |
|
| | |
| | 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++ |
| | 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) |
| | } |
| |
|