Files
nezha_domains/cmd/dashboard/controller/mcp.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

494 lines
16 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 — MCP (Model Context Protocol) server.
//
// 落地约束:
// - 仅支持 Streamable HTTP transport 的 POST 半边(请求-响应、无 SSE)。
// 首版面向 LLM 工具调用,不需要 server→client 主动推送。后续要做 GET SSE
// 长连接(resource subscription)时再补;客户端兼容 fallback 到普通 POST。
// - JSON-RPC 2.0 编解码内嵌于本文件,未引入第三方 MCP SDK:MCP 协议表面足够小
// initialize / tools/list / tools/call),自实现可控、零额外依赖。
// - 双层鉴权:闸 1(用户对 server 的所有权)由各 tool handler 调
// singleton.ServerShared.Get + Server.HasPermission;闸 2PAT scope)由
// mcpTool.RequiredScope 在 dispatch 之前过滤。
package controller
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
// --- JSON-RPC 2.0 wire types ---
type jsonRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type jsonRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id,omitempty"`
Result any `json:"result,omitempty"`
Error *jsonRPCError `json:"error,omitempty"`
}
type jsonRPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
const (
// JSON-RPC 标准错误码
rpcErrParse = -32700
rpcErrInvalidRequest = -32600
rpcErrMethodNotFound = -32601
rpcErrInvalidParams = -32602
rpcErrInternal = -32603
// MCP 自定义错误码(>= -32000 高位段)
rpcErrUnauthorized = -32001
rpcErrForbidden = -32002
)
// mcpJSONRPCMaxBodyBytes caps the JSON-RPC envelope size at the dashboard
// edge. Real fs.write base64 content goes through fs.transfer (capped
// separately by model.MCPFsTransferMaxSize) so tools/call params here are
// always small. The cap is intentionally generous (8 MiB) to allow
// per-request batched arguments while making OOM-via-decode impossible.
const mcpJSONRPCMaxBodyBytes = 8 * 1024 * 1024
// --- MCP types ---
// mcpServerInfo MCP initialize 响应的 serverInfo 字段。
type mcpServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type mcpInitializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ServerInfo mcpServerInfo `json:"serverInfo"`
}
// mcpToolDescriptor 是 tools/list 返回的单条 tool 描述。
type mcpToolDescriptor struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
// mcpToolsListResult tools/list 响应。
type mcpToolsListResult struct {
Tools []mcpToolDescriptor `json:"tools"`
}
// mcpContent 是 tools/call 响应里 content[] 的元素。
// 仅实现 text 类型;嵌入对象的结构化数据放在外层 structuredContent。
type mcpContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
// mcpToolCallResult tools/call 响应。
type mcpToolCallResult struct {
Content []mcpContent `json:"content"`
StructuredContent any `json:"structuredContent,omitempty"`
IsError bool `json:"isError,omitempty"`
}
// --- tool 注册框架 ---
// mcpToolHandler 实际业务逻辑:拿到 raw params + gin ctx,返回任意可序列化结构。
type mcpToolHandler func(c *gin.Context, params json.RawMessage) (any, error)
// mcpTool 是注册表里的单元:声明 + scope 要求 + 处理函数。
type mcpTool struct {
Name string
Description string
InputSchema map[string]any
RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami
Handler mcpToolHandler
}
var (
mcpToolsMu sync.RWMutex
mcpTools = map[string]*mcpTool{}
)
// registerMCPTool 把一个 tool 加进全局注册表。建议各 tool 文件在 init() 里调用。
func registerMCPTool(t *mcpTool) {
if t == nil || t.Name == "" || t.Handler == nil {
panic("registerMCPTool: invalid tool")
}
mcpToolsMu.Lock()
defer mcpToolsMu.Unlock()
if _, dup := mcpTools[t.Name]; dup {
panic("registerMCPTool: duplicate name " + t.Name)
}
mcpTools[t.Name] = t
}
// listRegisteredMCPTools 拷贝一份当前注册表(按名字稳定排序逻辑放在调用方)。
func listRegisteredMCPTools() []*mcpTool {
mcpToolsMu.RLock()
defer mcpToolsMu.RUnlock()
out := make([]*mcpTool, 0, len(mcpTools))
for _, t := range mcpTools {
out = append(out, t)
}
return out
}
// --- 入口 handler ---
// mcpEndpoint 处理 POST /mcp。
// 鉴权:上游 apiTokenAuthMiddleware 已经把 PAT 解析到 CtxKeyAuthorizedUser
// 此处只要确认有 PAT 即可(不接受裸 JWT,避免浏览器误触)。
func mcpEndpoint(c *gin.Context) {
if singleton.Conf == nil || !singleton.Conf.MCPEnabled() {
writeJSONRPCError(c, nil, rpcErrForbidden, "MCP is disabled by the dashboard administrator")
return
}
tok := APITokenFromContext(c)
if tok == nil {
// 同时返回 HTTP 401 + JSON-RPC error:标准 MCP HTTP client 依赖
// HTTP 401 触发 auth 重试/OAuth discoveryJSON-RPC body 保留旧字段
// 不打破 ScopeDenied 类内部断言。
writeJSONRPCErrorWithStatus(c, nil, rpcErrUnauthorized, "missing or invalid API token", http.StatusUnauthorized)
return
}
// MaxBytesReader 必须夹在 PAT 校验通过后、ShouldBindJSON 之前——
// 校验前限流可能让攻击者用伪造 token 触发 audit;校验后限流既挡住合法
// PAT 的 OOM,又不会让匿名请求走到 audit 路径。
if c.Request != nil && c.Request.Body != nil {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, mcpJSONRPCMaxBodyBytes)
}
// Consume the per-token budget before validating the request so malformed
// envelopes and malformed tools/call params cannot flood the dashboard
// without counting against the limiter. The outcome is applied after the
// method is known so tools/call still surfaces the rate limit as a tool
// error rather than a transport-level error.
rateLimited := !mcpRateLimiterShared.Allow(tok.ID)
var req jsonRPCRequest
if err := c.ShouldBindJSON(&req); err != nil {
if errors.Is(err, errors.New("http: request body too large")) || strings.Contains(err.Error(), "http: request body too large") {
writeJSONRPCErrorWithStatus(c, nil, rpcErrInvalidRequest, "request body exceeds MCP envelope size limit", http.StatusRequestEntityTooLarge)
return
}
// 限流优先:method 无从得知时,over-budget 请求即便 body 畸形也必须
// 走 429,否则攻击者能用畸形 body 在不计入限额的情况下持续刷 parse error。
if rateLimited {
writeJSONRPCErrorWithStatus(c, nil, rpcErrForbidden, "rate limit exceeded for this token", http.StatusTooManyRequests)
return
}
writeJSONRPCError(c, nil, rpcErrParse, "invalid json-rpc envelope: "+err.Error())
return
}
if req.JSONRPC != "2.0" || req.Method == "" {
if rateLimited {
writeJSONRPCErrorWithStatus(c, req.ID, rpcErrForbidden, "rate limit exceeded for this token", http.StatusTooManyRequests)
return
}
writeJSONRPCError(c, req.ID, rpcErrInvalidRequest, "invalid json-rpc envelope")
return
}
if rateLimited {
if req.Method == "tools/call" {
writeToolCallError(c, req.ID, model.MCPOutcomeRateLimited, "rate limit exceeded for this token")
return
}
writeJSONRPCErrorWithStatus(c, req.ID, rpcErrForbidden, "rate limit exceeded for this token", http.StatusTooManyRequests)
return
}
switch req.Method {
case "initialize":
writeJSONRPCResult(c, req.ID, mcpInitializeResult{
ProtocolVersion: "2024-11-05",
Capabilities: map[string]any{
"tools": map[string]any{"listChanged": false},
},
ServerInfo: mcpServerInfo{
Name: "nezha-mcp",
Version: singleton.Version,
},
})
case "notifications/initialized", "ping":
// 客户端通知或心跳;JSON-RPC 通知没有 id,但 ping 有 id 时返回空 result
if len(req.ID) > 0 && string(req.ID) != "null" {
writeJSONRPCResult(c, req.ID, struct{}{})
return
}
c.Status(http.StatusAccepted)
case "tools/list":
writeJSONRPCResult(c, req.ID, mcpToolsListResult{
Tools: buildToolDescriptors(),
})
case "tools/call":
handleToolsCall(c, &req, tok)
default:
writeJSONRPCError(c, req.ID, rpcErrMethodNotFound, "method not supported: "+req.Method)
}
}
func buildToolDescriptors() []mcpToolDescriptor {
tools := listRegisteredMCPTools()
out := make([]mcpToolDescriptor, 0, len(tools))
for _, t := range tools {
out = append(out, mcpToolDescriptor{
Name: t.Name,
Description: t.Description,
InputSchema: t.InputSchema,
})
}
return out
}
// toolCallParams 是 tools/call 的 params 结构。
type toolCallParams struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments,omitempty"`
}
func handleToolsCall(c *gin.Context, req *jsonRPCRequest, tok *model.APIToken) {
var p toolCallParams
if len(req.Params) > 0 {
if err := json.Unmarshal(req.Params, &p); err != nil {
writeJSONRPCError(c, req.ID, rpcErrInvalidParams, "invalid arguments: "+err.Error())
return
}
}
if p.Name == "" {
writeJSONRPCError(c, req.ID, rpcErrInvalidParams, "tool name required")
return
}
mcpToolsMu.RLock()
tool, ok := mcpTools[p.Name]
mcpToolsMu.RUnlock()
if !ok {
writeJSONRPCError(c, req.ID, rpcErrMethodNotFound, "unknown tool: "+p.Name)
return
}
uid := uint64(0)
if u, ok := c.Get(model.CtxKeyAuthorizedUser); ok {
if user, ok := u.(*model.User); ok && user != nil {
uid = user.ID
}
}
startedAt := time.Now()
audit := model.MCPAuditLog{
UserID: uid,
TokenID: tok.ID,
Tool: p.Name,
IP: c.GetString(model.CtxKeyRealIPStr),
}
finish := func(outcome, errCode, errMsg string, result any) {
audit.Outcome = outcome
audit.ErrorCode = errCode
audit.ErrorMsg = truncateString(errMsg, 512)
audit.DurationMs = time.Since(startedAt).Milliseconds()
audit.ServerID = extractServerID(p.Arguments)
mcpAuditWrite(audit, p.Arguments)
if outcome == model.MCPOutcomeOK {
textPayload := "{}"
if result != nil {
if b, err := json.Marshal(result); err == nil {
textPayload = string(b)
}
}
writeJSONRPCResult(c, req.ID, mcpToolCallResult{
Content: []mcpContent{{Type: "text", Text: textPayload}},
StructuredContent: result,
})
return
}
writeJSONRPCResult(c, req.ID, mcpToolCallResult{
Content: []mcpContent{{Type: "text", Text: errMsg}},
IsError: true,
StructuredContent: map[string]string{
"error_code": errCode,
"error": errMsg,
},
})
}
if tool.RequiredScope != "" && !tok.HasScope(tool.RequiredScope) {
finish(model.MCPOutcomeScopeDenied, model.MCPOutcomeScopeDenied,
"missing required scope: "+tool.RequiredScope, nil)
return
}
// 让 PAT 吊销能立即中断进行中的 tools/call(如 server.exec 最长 ~305s):
// 派生一个可取消 ctx 注入 c.Request,下游 CallAgent 用 c.Request.Context()
// 即会观察到取消;cancel 注册进吊销表,deleteAPIToken 会立刻触发它。
if c.Request != nil {
callCtx, cancel := context.WithCancel(c.Request.Context())
defer cancel()
deregister := registerPATConnection(c, cancel)
defer deregister()
c.Request = c.Request.WithContext(callCtx)
}
result, err := tool.Handler(c, p.Arguments)
if err != nil {
code, msg := classifyToolError(err)
finish(code, code, msg, nil)
return
}
finish(model.MCPOutcomeOK, "", "", result)
}
// classifyToolError 把任何 handler 返回的 error 归类成审计 outcome + 安全错误消息。
// 优先匹配 mcpError 自带的 Code;否则匹配已知的 rpc.ErrAgent* 类型,最后回退 internal。
func classifyToolError(err error) (code, msg string) {
if me, ok := err.(*mcpError); ok {
return me.Code, me.Msg
}
if errors.Is(err, rpc.ErrAgentOffline) {
return model.MCPOutcomeServerOffline, "agent offline"
}
if errors.Is(err, rpc.ErrAgentTimeout) {
return model.MCPOutcomeAgentTimeout, "agent did not respond within timeout"
}
if errors.Is(err, rpc.ErrMCPDisabled) {
// kill switch 触发的中断必须独立成 outcome,避免审计/SIEM 把
// “管理员关了 MCP”误报成 agent 故障;错误文本透传原始原因。
return model.MCPOutcomeMCPDisabled, err.Error()
}
return model.MCPOutcomeAgentError, err.Error()
}
// extractServerID 从 raw arguments JSON 里提取 server_idbest-effort,只用于审计字段)。
func extractServerID(raw json.RawMessage) uint64 {
if len(raw) == 0 {
return 0
}
var probe struct {
ServerID uint64 `json:"server_id"`
}
_ = json.Unmarshal(raw, &probe)
return probe.ServerID
}
func truncateString(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max]
}
// --- wire writers ---
func writeJSONRPCResult(c *gin.Context, id json.RawMessage, result any) {
c.JSON(http.StatusOK, jsonRPCResponse{
JSONRPC: "2.0",
ID: id,
Result: result,
})
}
func writeJSONRPCError(c *gin.Context, id json.RawMessage, code int, message string) {
writeJSONRPCErrorWithStatus(c, id, code, message, http.StatusOK)
}
func writeToolCallError(c *gin.Context, id json.RawMessage, errCode, errMsg string) {
writeJSONRPCResult(c, id, mcpToolCallResult{
Content: []mcpContent{{Type: "text", Text: errMsg}},
IsError: true,
StructuredContent: map[string]string{
"error_code": errCode,
"error": errMsg,
},
})
}
func writeJSONRPCErrorWithStatus(c *gin.Context, id json.RawMessage, code int, message string, status int) {
c.JSON(status, jsonRPCResponse{
JSONRPC: "2.0",
ID: id,
Error: &jsonRPCError{Code: code, Message: message},
})
}
// --- 错误语义 ---
// mcpError 是 tool handler 可以返回的语义化错误。
// dispatch 根据 Code 决定 audit outcome 与 JSON-RPC 错误码(如果命中 rpcErr* 域)。
type mcpError struct {
Code string
Msg string
}
func (e *mcpError) Error() string { return e.Msg }
func newMCPError(code, msg string) *mcpError { return &mcpError{Code: code, Msg: msg} }
// 预制错误
var (
errMCPInvalidArgs = func(s string) *mcpError { return newMCPError(model.MCPOutcomeInvalidArgs, s) }
errMCPPermDenied = newMCPError(model.MCPOutcomePermDenied, "permission denied")
errMCPScopeDenied = func(s string) *mcpError {
return newMCPError(model.MCPOutcomeScopeDenied, "missing required scope: "+s)
}
errMCPServerOffline = newMCPError(model.MCPOutcomeServerOffline, "agent offline")
errMCPAgentTimeout = newMCPError(model.MCPOutcomeAgentTimeout, "agent did not respond within timeout")
errMCPUnsupported = newMCPError(model.MCPOutcomeUnsupportedAgent, "agent does not support this MCP capability; please upgrade the agent")
)
// --- 共用工具 ---
var errNoToken = errors.New("no api token in context")
// decodeToolArgs 是 tool handler 用来反序列化 arguments 的辅助。
func decodeToolArgs(raw json.RawMessage, out any) error {
if len(raw) == 0 {
return nil
}
if err := json.Unmarshal(raw, out); err != nil {
return fmt.Errorf("invalid arguments: %w", err)
}
return nil
}
// requireServerAccess 是 tool handler 共用的「闸 1 + 闸 2 服务器白名单」组合校验。
// 通过返回 *model.Server;失败返回带语义 Code 的 mcpError,便于 dispatch 归类审计。
func requireServerAccess(c *gin.Context, serverID uint64) (*model.Server, error) {
if serverID == 0 {
return nil, errMCPInvalidArgs("server_id required")
}
tok := APITokenFromContext(c)
if tok != nil && !tok.CanAccessServer(serverID) {
return nil, errMCPPermDenied
}
server, _ := singleton.ServerShared.Get(serverID)
if server == nil {
return nil, errMCPServerOffline
}
if !server.HasPermission(c) {
return nil, errMCPPermDenied
}
return server, nil
}