Merge pull request #62 from router-for-me/dev

feat(auth, docs): add SDK guides and local password support for manag…
This commit is contained in:
Luis Pater
2025-09-25 11:42:49 +08:00
committed by GitHub
13 changed files with 1111 additions and 32 deletions

View File

@@ -3,6 +3,7 @@
package management
import (
"crypto/subtle"
"fmt"
"net/http"
"strings"
@@ -33,6 +34,8 @@ type Handler struct {
authManager *coreauth.Manager
usageStats *usage.RequestStatistics
tokenStore sdkAuth.TokenStore
localPassword string
}
// NewHandler creates a new management handler instance.
@@ -56,6 +59,9 @@ func (h *Handler) SetAuthManager(manager *coreauth.Manager) { h.authManager = ma
// 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.
@@ -65,10 +71,10 @@ func (h *Handler) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
clientIP := c.ClientIP()
localClient := clientIP == "127.0.0.1" || clientIP == "::1"
// For remote IPs, enforce allow-remote-management and ban checks
if !(clientIP == "127.0.0.1" || clientIP == "::1") {
// Check if IP is currently blocked
fail := func() {}
if !localClient {
h.attemptsMu.Lock()
ai := h.failedAttempts[clientIP]
if ai != nil {
@@ -86,11 +92,25 @@ func (h *Handler) Middleware() gin.HandlerFunc {
}
h.attemptsMu.Unlock()
allowRemote := h.cfg.RemoteManagement.AllowRemote
if !allowRemote {
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 == "" {
@@ -112,36 +132,32 @@ func (h *Handler) Middleware() gin.HandlerFunc {
provided = c.GetHeader("X-Management-Key")
}
if !(clientIP == "127.0.0.1" || clientIP == "::1") {
// For remote IPs, enforce key and track failures
fail := func() {
h.attemptsMu.Lock()
ai := h.failedAttempts[clientIP]
if ai == nil {
ai = &attemptInfo{}
h.failedAttempts[clientIP] = ai
}
ai.count++
if ai.count >= maxFailures {
ai.blockedUntil = time.Now().Add(banDuration)
ai.count = 0
}
h.attemptsMu.Unlock()
}
if provided == "" {
if provided == "" {
if !localClient {
fail()
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
return
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
return
}
if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
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
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
return
}
// Success: reset failed count for this IP
if !localClient {
h.attemptsMu.Lock()
if ai := h.failedAttempts[clientIP]; ai != nil {
ai.count = 0

View File

@@ -33,6 +33,7 @@ type serverOptionConfig struct {
engineConfigurator func(*gin.Engine)
routerConfigurator func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)
requestLoggerFactory func(*config.Config, string) logging.RequestLogger
localPassword string
}
// ServerOption customises HTTP server construction.
@@ -63,6 +64,13 @@ func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *conf
}
}
// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.
func WithLocalManagementPassword(password string) ServerOption {
return func(cfg *serverOptionConfig) {
cfg.localPassword = password
}
}
// WithRequestLoggerFactory customises request logger creation.
func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {
return func(cfg *serverOptionConfig) {
@@ -163,6 +171,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
s.applyAccessConfig(cfg)
// Initialize management handler
s.mgmt = managementHandlers.NewHandler(cfg, configFilePath, authManager)
if optionState.localPassword != "" {
s.mgmt.SetLocalPassword(optionState.localPassword)
}
// Setup routes
s.setupRoutes()

View File

@@ -21,10 +21,12 @@ import (
// Parameters:
// - cfg: The application configuration
// - configPath: The path to the configuration file
func StartService(cfg *config.Config, configPath string) {
// - localPassword: Optional password accepted for local management requests
func StartService(cfg *config.Config, configPath string, localPassword string) {
service, err := cliproxy.NewBuilder().
WithConfig(cfg).
WithConfigPath(configPath).
WithLocalManagementPassword(localPassword).
Build()
if err != nil {
log.Fatalf("failed to build proxy service: %v", err)