mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
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>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/nezhahq/nezha/model"
|
||||
"github.com/nezhahq/nezha/service/rpc"
|
||||
)
|
||||
|
||||
// server.exec — 非交互一次性命令。
|
||||
// 协议约束(agent 端强制):
|
||||
// - 不开 pty
|
||||
// - 默认 30s 超时,硬上限 300s
|
||||
// - stdout/stderr 各自最多 64KB(默认),硬上限 1MB
|
||||
// - 受 agent 配置 DisableCommandExecute 影响
|
||||
//
|
||||
// LLM 要用 shell 特性(管道、重定向)必须显式传 cmd="sh" args=["-c","..."],
|
||||
// 这样审计日志能完整记录被执行的指令。
|
||||
const mcpExecMaxTimeoutSec uint32 = 300
|
||||
|
||||
type execArgs struct {
|
||||
ServerID uint64 `json:"server_id"`
|
||||
Cmd string `json:"cmd"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
Cwd string `json:"cwd,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
TimeoutSeconds uint32 `json:"timeout_seconds,omitempty"`
|
||||
Stdin string `json:"stdin,omitempty"`
|
||||
MaxOutputBytes uint32 `json:"max_output_bytes,omitempty"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerMCPTool(&mcpTool{
|
||||
Name: "server.exec",
|
||||
Description: "Run a non-interactive command on the target server and return stdout/stderr/exit_code. No pty. Use cmd='sh' args=['-c', '...'] for shell features.",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"server_id": map[string]any{"type": "integer"},
|
||||
"cmd": map[string]any{"type": "string"},
|
||||
"args": map[string]any{"type": "array", "items": map[string]any{"type": "string"}},
|
||||
"cwd": map[string]any{"type": "string"},
|
||||
"env": map[string]any{"type": "object"},
|
||||
"timeout_seconds": map[string]any{"type": "integer", "minimum": 1, "maximum": 300},
|
||||
"stdin": map[string]any{"type": "string"},
|
||||
"max_output_bytes": map[string]any{"type": "integer"},
|
||||
},
|
||||
"required": []string{"server_id", "cmd"},
|
||||
},
|
||||
RequiredScope: model.ScopeServerExec,
|
||||
Handler: handleServerExec,
|
||||
})
|
||||
}
|
||||
|
||||
func handleServerExec(c *gin.Context, raw json.RawMessage) (any, error) {
|
||||
var args execArgs
|
||||
if err := decodeToolArgs(raw, &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.TimeoutSeconds > mcpExecMaxTimeoutSec {
|
||||
return nil, errMCPInvalidArgs("timeout_seconds out of range; must be 1..300")
|
||||
}
|
||||
srv, err := requireServerAccess(c, args.ServerID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := requireAgentSupportsMCP(srv); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Cmd == "" {
|
||||
return nil, errMCPInvalidArgs("cmd required")
|
||||
}
|
||||
|
||||
req := model.ExecRequest{
|
||||
Cmd: args.Cmd,
|
||||
Args: args.Args,
|
||||
Cwd: args.Cwd,
|
||||
Env: args.Env,
|
||||
TimeoutSeconds: args.TimeoutSeconds,
|
||||
Stdin: args.Stdin,
|
||||
MaxOutputBytes: args.MaxOutputBytes,
|
||||
}
|
||||
|
||||
timeout := callAgentTimeout(args.TimeoutSeconds, 30)
|
||||
raw2, err := rpc.CallAgent(c.Request.Context(), args.ServerID, model.TaskTypeExec, req, timeout)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res model.ExecResult
|
||||
if err := json.Unmarshal(raw2, &res); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// ExecResult.Error means the agent refused / failed to run the command
|
||||
// (disabled, empty cmd, Start/process-group failure). Surface it like fs.*
|
||||
// handlers do, so MCP isError=true and audit outcome=agent_error. Non-zero
|
||||
// ExitCode alone is a normal command outcome, not a tool error.
|
||||
if res.Error != "" {
|
||||
return nil, errors.New(res.Error)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// callAgentTimeout 给 dashboard 侧 CallAgent 计算等待上限。
|
||||
// 在用户请求的 timeout 基础上加 5s buffer,让 agent 端的 hard timeout 先触发,
|
||||
// 这样 dashboard 收到的总是结构化结果(包含 timed_out=true),
|
||||
// 而不是 ErrAgentTimeout。
|
||||
func callAgentTimeout(reqTimeoutSec uint32, defaultSec uint32) time.Duration {
|
||||
t := reqTimeoutSec
|
||||
if t == 0 {
|
||||
t = defaultSec
|
||||
}
|
||||
return time.Duration(t+5) * time.Second
|
||||
}
|
||||
Reference in New Issue
Block a user