Files
nezha_domains/cmd/dashboard/controller/mcp_ratelimit.go
T
naibaandcloudcode e8dabf5bc6 feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-30 15:56:44 +00:00

91 lines
2.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"sync"
"time"
)
// MCPRateLimiter 实现按 token 的双层 token bucket
// - 秒级:默认 10 req/s,应对单个 LLM 突发
// - 分钟级:默认 120 req/min,应对长时间刷
//
// 实现走简单 sliding window(按桶截断的 counter),轻量、O(1)
// 进程内即可,未持久化——重启等价于配额刷新,可接受。
type MCPRateLimiter struct {
mu sync.Mutex
perToken map[uint64]*tokenWindow
secLimit int
minLimit int
lastPrune time.Time
}
type tokenWindow struct {
secBucketStart time.Time
secCount int
minBucketStart time.Time
minCount int
}
// mcpRateLimiterPruneInterval bounds how often Allow sweeps the map. Without
// eviction the map kept one entry per token ID ever seen, so PAT churn grew
// it without bound. A token idle longer than its minute bucket carries no
// live budget, so dropping it is lossless; the interval keeps the sweep
// amortized O(1) per call instead of O(map) every call.
const mcpRateLimiterPruneInterval = time.Minute
func newMCPRateLimiter(secLimit, minLimit int) *MCPRateLimiter {
return &MCPRateLimiter{
perToken: make(map[uint64]*tokenWindow),
secLimit: secLimit,
minLimit: minLimit,
}
}
// pruneStaleLocked drops windows whose minute bucket started more than one
// minute ago: such a token has no accumulated budget left, so removing it
// cannot change any future Allow decision. Caller must hold r.mu.
func (r *MCPRateLimiter) pruneStaleLocked(now time.Time) {
for id, w := range r.perToken {
if now.Sub(w.minBucketStart) >= time.Minute {
delete(r.perToken, id)
}
}
}
// Allow 返回是否允许本次调用。被拒返回 false。
// tokenID = 0 时不限流(管理路径或匿名)。
func (r *MCPRateLimiter) Allow(tokenID uint64) bool {
if tokenID == 0 {
return true
}
now := time.Now()
r.mu.Lock()
defer r.mu.Unlock()
if now.Sub(r.lastPrune) >= mcpRateLimiterPruneInterval {
r.pruneStaleLocked(now)
r.lastPrune = now
}
w, ok := r.perToken[tokenID]
if !ok {
w = &tokenWindow{secBucketStart: now, minBucketStart: now}
r.perToken[tokenID] = w
}
if now.Sub(w.secBucketStart) >= time.Second {
w.secBucketStart = now
w.secCount = 0
}
if now.Sub(w.minBucketStart) >= time.Minute {
w.minBucketStart = now
w.minCount = 0
}
if w.secCount >= r.secLimit || w.minCount >= r.minLimit {
return false
}
w.secCount++
w.minCount++
return true
}
// 全局单例,参数固定(生产可观测后再考虑配置化)。
var mcpRateLimiterShared = newMCPRateLimiter(10, 120)