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

149 lines
3.6 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 (
"encoding/json"
"time"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// server.list 返回当前 PAT 可见的服务器精简列表。
//
// 输出字段刻意保持小:LLM context 很贵,列 100 台机器时不要把整张 Host/State 表
// 全塞进去。需要细节时再调 server.get。
type serverListItem struct {
ID uint64 `json:"id"`
Name string `json:"name"`
UUID string `json:"uuid,omitempty"`
IPv4 string `json:"ipv4,omitempty"`
IPv6 string `json:"ipv6,omitempty"`
Online bool `json:"online"`
Platform string `json:"platform,omitempty"`
Arch string `json:"arch,omitempty"`
LastActive time.Time `json:"last_active,omitempty"`
}
type serverListArgs struct {
OnlineOnly bool `json:"online_only,omitempty"`
}
func init() {
registerMCPTool(&mcpTool{
Name: "server.list",
Description: "List servers visible to the current API token. Returns minimal metadata; call server.get for full details.",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{
"online_only": map[string]any{
"type": "boolean",
"description": "If true, only return servers that have reported within the last 30s.",
},
},
},
RequiredScope: model.ScopeServerRead,
Handler: handleServerList,
})
registerMCPTool(&mcpTool{
Name: "server.get",
Description: "Return full Host/State snapshot for a single server.",
InputSchema: serverGetSchema(),
RequiredScope: model.ScopeServerRead,
Handler: handleServerGet,
})
}
func handleServerList(c *gin.Context, raw json.RawMessage) (any, error) {
var args serverListArgs
if err := decodeToolArgs(raw, &args); err != nil {
return nil, err
}
tok := APITokenFromContext(c)
if tok == nil {
return nil, errNoToken
}
slist := singleton.ServerShared.GetSortedList()
now := time.Now()
const onlineWindow = 30 * time.Second
out := make([]serverListItem, 0, len(slist))
for _, s := range slist {
if s == nil {
continue
}
// 闸 1:复用现有用户权限过滤
if !s.HasPermission(c) {
continue
}
// 闸 2PAT 的 server 白名单(若已设置)
if !tok.CanAccessServer(s.ID) {
continue
}
online := !s.LastActive.IsZero() && now.Sub(s.LastActive) < onlineWindow
if args.OnlineOnly && !online {
continue
}
item := serverListItem{
ID: s.ID,
Name: s.Name,
UUID: s.UUID,
Online: online,
LastActive: s.LastActive,
}
if s.Host != nil {
item.Platform = s.Host.Platform
item.Arch = s.Host.Arch
}
if s.GeoIP != nil {
item.IPv4 = s.GeoIP.IP.IPv4Addr
item.IPv6 = s.GeoIP.IP.IPv6Addr
}
out = append(out, item)
}
return out, nil
}
// server.get
type serverGetArgs struct {
ServerID uint64 `json:"server_id"`
}
func serverGetSchema() map[string]any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"server_id": map[string]any{
"type": "integer",
"description": "Target server ID.",
},
},
"required": []string{"server_id"},
}
}
func handleServerGet(c *gin.Context, raw json.RawMessage) (any, error) {
var args serverGetArgs
if err := decodeToolArgs(raw, &args); err != nil {
return nil, err
}
s, err := requireServerAccess(c, args.ServerID)
if err != nil {
return nil, err
}
return map[string]any{
"id": s.ID,
"name": s.Name,
"uuid": s.UUID,
"note": s.Note,
"public_note": s.PublicNote,
"host": s.Host,
"state": s.State,
"geoip": s.GeoIP,
"last_active": s.LastActive,
}, nil
}