mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
278 lines
7.4 KiB
Go
278 lines
7.4 KiB
Go
// 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 <key> 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)
|
|
}
|