feat(api): implement protocol multiplexer and Redis queue for usage integration

- Added `protocol_multiplexer.go`, enabling support for both HTTP and Redis protocols on a single listener.
- Introduced `redis_queue_protocol.go` to handle Redis-compatible RESP commands for queue management.
- Integrated `redisqueue` package, supporting in-memory queuing with expiration pruning.
- Updated server initialization to manage a shared listener and multiplex connections.
- Adjusted `Handler` to adopt `AuthenticateManagementKey` for modular key validation, supporting both HTTP and Redis flows.
This commit is contained in:
Luis Pater
2026-04-25 16:12:35 +08:00
parent be0fe6fab3
commit 28d78273e4
13 changed files with 1490 additions and 102 deletions
+99 -89
View File
@@ -152,9 +152,6 @@ func (h *Handler) SetPostAuthHook(hook coreauth.PostAuthHook) {
// 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) {
c.Header("X-CPA-VERSION", buildinfo.Version)
c.Header("X-CPA-COMMIT", buildinfo.Commit)
@@ -162,64 +159,6 @@ func (h *Handler) Middleware() gin.HandlerFunc {
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++
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
}
// Accept either Authorization: Bearer <key> or X-Management-Key
var provided string
@@ -235,44 +174,98 @@ func (h *Handler) Middleware() gin.HandlerFunc {
provided = c.GetHeader("X-Management-Key")
}
if provided == "" {
if !localClient {
fail()
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
allowed, statusCode, errMsg := h.AuthenticateManagementKey(clientIP, localClient, provided)
if !allowed {
c.AbortWithStatusJSON(statusCode, gin.H{"error": errMsg})
return
}
c.Next()
}
}
if localClient {
if lp := h.localPassword; lp != "" {
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
c.Next()
return
// AuthenticateManagementKey verifies the provided management key for the given client.
// It mirrors the behaviour of Middleware() so non-HTTP callers can reuse the same logic.
func (h *Handler) AuthenticateManagementKey(clientIP string, localClient bool, provided string) (bool, int, string) {
const maxFailures = 5
const banDuration = 30 * time.Minute
if h == nil {
return false, http.StatusForbidden, "remote management disabled"
}
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()
return false, http.StatusForbidden, fmt.Sprintf("IP banned due to too many failed attempts. Try again in %s", remaining)
}
// Ban expired, reset state
ai.blockedUntil = time.Time{}
ai.count = 0
}
}
h.attemptsMu.Unlock()
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 !allowRemote {
return false, http.StatusForbidden, "remote management disabled"
}
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
if !localClient {
fail()
fail = func() {
h.attemptsMu.Lock()
aip := h.failedAttempts[clientIP]
if aip == nil {
aip = &attemptInfo{}
h.failedAttempts[clientIP] = aip
}
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
return
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 == "" {
return false, http.StatusForbidden, "remote management key not set"
}
if provided == "" {
if !localClient {
fail()
}
return false, http.StatusUnauthorized, "missing management key"
}
if localClient {
if lp := h.localPassword; lp != "" {
if subtle.ConstantTimeCompare([]byte(provided), []byte(lp)) == 1 {
return true, 0, ""
}
}
}
if envSecret != "" && subtle.ConstantTimeCompare([]byte(provided), []byte(envSecret)) == 1 {
if !localClient {
h.attemptsMu.Lock()
if ai := h.failedAttempts[clientIP]; ai != nil {
@@ -281,9 +274,26 @@ func (h *Handler) Middleware() gin.HandlerFunc {
}
h.attemptsMu.Unlock()
}
c.Next()
return true, 0, ""
}
if secretHash == "" || bcrypt.CompareHashAndPassword([]byte(secretHash), []byte(provided)) != nil {
if !localClient {
fail()
}
return false, http.StatusUnauthorized, "invalid management key"
}
if !localClient {
h.attemptsMu.Lock()
if ai := h.failedAttempts[clientIP]; ai != nil {
ai.count = 0
ai.blockedUntil = time.Time{}
}
h.attemptsMu.Unlock()
}
return true, 0, ""
}
// persist saves the current in-memory config to disk.