mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50: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,493 @@
|
||||
// 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;闸 2(PAT 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 discovery;JSON-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_id(best-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
|
||||
}
|
||||
Reference in New Issue
Block a user