mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-02 20:40:52 +08:00
- Added extensive SDK usage guides for `cliproxy`, `sdk/access`, and watcher integration. - Introduced `--password` flag for specifying local management access passwords. - Enhanced management API with local password checks to secure localhost requests. - Updated documentation to reflect the new password functionality.
232 lines
6.3 KiB
Go
232 lines
6.3 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"
|
|
"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 sdkAuth.TokenStore
|
|
|
|
localPassword string
|
|
}
|
|
|
|
// NewHandler creates a new management handler instance.
|
|
func NewHandler(cfg *config.Config, configFilePath string, manager *coreauth.Manager) *Handler {
|
|
return &Handler{
|
|
cfg: cfg,
|
|
configFilePath: configFilePath,
|
|
failedAttempts: make(map[string]*attemptInfo),
|
|
authManager: manager,
|
|
usageStats: usage.GetRequestStatistics(),
|
|
tokenStore: sdkAuth.GetTokenStore(),
|
|
}
|
|
}
|
|
|
|
// 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 }
|
|
|
|
// 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"
|
|
|
|
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 !h.cfg.RemoteManagement.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()
|
|
}
|
|
}
|
|
secret := h.cfg.RemoteManagement.SecretKey
|
|
if secret == "" {
|
|
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 err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != 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)
|
|
}
|