diff --git a/cmd/dashboard/controller/api_token_optional_scope_test.go b/cmd/dashboard/controller/api_token_optional_scope_test.go index 1b276908..a904eef5 100644 --- a/cmd/dashboard/controller/api_token_optional_scope_test.go +++ b/cmd/dashboard/controller/api_token_optional_scope_test.go @@ -24,7 +24,7 @@ func setupOptionalAuthRouter(t *testing.T, plainToken string) *httptest.Server { stub := func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) } optionalAuth := r.Group("/api/v1", authMw) - optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeServerRead), stub) + optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeInventoryRead), stub) optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), stub) optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), stub) @@ -56,7 +56,7 @@ func TestOptionalAuth_PATWithMatchingScopeAllowed(t *testing.T) { cleanup, uid := setupMCPTest(t) defer cleanup() - _, plain := mkToken(t, uid, []string{model.ScopeServerRead, model.ScopeServiceRead}, nil) + _, plain := mkToken(t, uid, []string{model.ScopeInventoryRead, model.ScopeServerRead, model.ScopeServiceRead}, nil) ts := setupOptionalAuthRouter(t, plain) for _, path := range []string{ diff --git a/cmd/dashboard/controller/api_token_scope_test.go b/cmd/dashboard/controller/api_token_scope_test.go index 89971537..47cf340f 100644 --- a/cmd/dashboard/controller/api_token_scope_test.go +++ b/cmd/dashboard/controller/api_token_scope_test.go @@ -20,14 +20,14 @@ func setupRESTScopeServer(t *testing.T) (*httptest.Server, string, func()) { t.Helper() cleanupBase, uid := setupMCPTest(t) - _, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil) + _, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil) gin.SetMode(gin.TestMode) r := gin.New() pat := apiTokenAuthMiddleware() r.GET("/api/v1/server", pat, - restScopeMiddleware(model.ScopeServerRead), + restScopeMiddleware(model.ScopeInventoryRead), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }, ) r.POST("/api/v1/server/config", @@ -122,7 +122,7 @@ func TestRESTScope_JWTUserSkipsScope(t *testing.T) { c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: model.RoleMember}) c.Next() }, - restScopeMiddleware(model.ScopeServerRead), + restScopeMiddleware(model.ScopeInventoryRead), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }, ) ts := httptest.NewServer(r) @@ -148,7 +148,7 @@ func TestRESTScope_NezhaAllUnlocksEverything(t *testing.T) { ) r.POST("/api/v1/batch-delete/server", pat, - restScopeMiddleware(model.ScopeServerDelete), + restScopeMiddleware(model.ScopeInventoryDelete), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }, ) ts := httptest.NewServer(r) @@ -193,7 +193,7 @@ func TestRESTScope_GoodPATClearsWAFCounter(t *testing.T) { cleanupBase, uid := setupMCPTest(t) defer cleanupBase() - _, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil) + _, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil) gin.SetMode(gin.TestMode) r := gin.New() @@ -203,7 +203,7 @@ func TestRESTScope_GoodPATClearsWAFCounter(t *testing.T) { }) r.GET("/api/v1/server", apiTokenAuthMiddleware(), - restScopeMiddleware(model.ScopeServerRead), + restScopeMiddleware(model.ScopeInventoryRead), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }, ) ts := httptest.NewServer(r) diff --git a/cmd/dashboard/controller/controller.go b/cmd/dashboard/controller/controller.go index 6d4d8985..5ce81998 100644 --- a/cmd/dashboard/controller/controller.go +++ b/cmd/dashboard/controller/controller.go @@ -88,8 +88,8 @@ func routers(r *gin.Engine, frontendDist fs.FS) { optionalAuthMw := utils.IfOr(singleton.Conf.ForceAuth, authMw, patOrFallbackAuthMiddleware(patMw, fallbackAuthMw)) optionalAuth := api.Group("", optionalAuthMw) - optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeServerRead), commonHandler(serverStream)) - optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeServerRead), commonHandler(listServerGroup)) + optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(serverStream)) + optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(listServerGroup)) optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(showService)) optionalAuth.GET("/service/server", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerWithServices)) @@ -112,21 +112,25 @@ func routers(r *gin.Engine, frontendDist fs.FS) { auth.POST("/api-tokens", patForbidden, commonHandler(createAPIToken)) auth.DELETE("/api-tokens/:id", patForbidden, commonHandler(deleteAPIToken)) - // server / terminal / fm / transfer 共享 nezha:server:* 资源族 + // 资源族划分: + // - nezha:inventory:* —— 对“服务器台账”的枚举与删除(列出 server / server-group、 + // 删除 server / server-group)。这是管理后台清单管理动作。 + // - nezha:server:* —— 对已知 server 的运行态操作(exec、文件读写、编辑配置、 + // force-update、batch-move)。 auth.POST("/terminal", restScopeMiddleware(model.ScopeServerExec), commonHandler(createTerminal)) auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream)) auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM)) auth.GET("/ws/file/:id", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(fmStream)) - auth.GET("/server", restScopeMiddleware(model.ScopeServerRead), listHandler(listServer)) + auth.GET("/server", restScopeMiddleware(model.ScopeInventoryRead), listHandler(listServer)) auth.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer)) auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig)) auth.POST("/server/config", restScopeMiddleware(model.ScopeServerWrite), commonHandler(setServerConfig)) - auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeServerDelete), commonHandler(batchDeleteServer)) + auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServer)) auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer)) auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer)) auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup)) auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup)) - auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeServerDelete), commonHandler(batchDeleteServerGroup)) + auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServerGroup)) // transfer — 严格使用 nezha:transfer 资源族 scope(read/write/delete)。 // 注意:曾经计划让 nezha:server:read 兼听只读 transfer,但 restScopeMiddleware diff --git a/cmd/dashboard/controller/mcp.go b/cmd/dashboard/controller/mcp.go index 4dfddd89..9788b56a 100644 --- a/cmd/dashboard/controller/mcp.go +++ b/cmd/dashboard/controller/mcp.go @@ -85,9 +85,10 @@ type mcpInitializeResult struct { // mcpToolDescriptor 是 tools/list 返回的单条 tool 描述。 type mcpToolDescriptor struct { - Name string `json:"name"` - Description string `json:"description"` - InputSchema map[string]any `json:"inputSchema"` + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` + OutputSchema map[string]any `json:"outputSchema,omitempty"` } // mcpToolsListResult tools/list 响应。 @@ -119,7 +120,8 @@ type mcpTool struct { Name string Description string InputSchema map[string]any - RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami) + OutputSchema map[string]any // 可选;声明 structuredContent 形状,供严格客户端校验 + RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami) Handler mcpToolHandler } @@ -253,9 +255,10 @@ func buildToolDescriptors() []mcpToolDescriptor { out := make([]mcpToolDescriptor, 0, len(tools)) for _, t := range tools { out = append(out, mcpToolDescriptor{ - Name: t.Name, - Description: t.Description, - InputSchema: t.InputSchema, + Name: t.Name, + Description: t.Description, + InputSchema: t.InputSchema, + OutputSchema: t.OutputSchema, }) } return out diff --git a/cmd/dashboard/controller/mcp_end_to_end_test.go b/cmd/dashboard/controller/mcp_end_to_end_test.go index ec1725a4..43dcdfb1 100644 --- a/cmd/dashboard/controller/mcp_end_to_end_test.go +++ b/cmd/dashboard/controller/mcp_end_to_end_test.go @@ -228,9 +228,10 @@ func setupEndToEnd(t *testing.T) (*httptest.Server, string, func()) { } _, plain := mkToken(t, uid, []string{ + model.ScopeInventoryRead, + model.ScopeInventoryDelete, model.ScopeServerRead, model.ScopeServerExec, - model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete, }, nil) diff --git a/cmd/dashboard/controller/mcp_sdk_compat_test.go b/cmd/dashboard/controller/mcp_sdk_compat_test.go index f4e4fd56..d1f3749d 100644 --- a/cmd/dashboard/controller/mcp_sdk_compat_test.go +++ b/cmd/dashboard/controller/mcp_sdk_compat_test.go @@ -53,6 +53,8 @@ func setupSDKCompat(t *testing.T) (string, string, func()) { srv.SetTaskStream(&e2eStream{dispatch: agentSim}) _, plain := mkToken(t, uid, []string{ + model.ScopeInventoryRead, + model.ScopeInventoryDelete, model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete, diff --git a/cmd/dashboard/controller/mcp_tools_exec.go b/cmd/dashboard/controller/mcp_tools_exec.go index e54a2cb6..132be0e3 100644 --- a/cmd/dashboard/controller/mcp_tools_exec.go +++ b/cmd/dashboard/controller/mcp_tools_exec.go @@ -51,6 +51,19 @@ func init() { }, "required": []string{"server_id", "cmd"}, }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "exit_code": map[string]any{"type": "integer"}, + "stdout": map[string]any{"type": "string"}, + "stderr": map[string]any{"type": "string"}, + "duration_ms": map[string]any{"type": "integer"}, + "stdout_truncated": map[string]any{"type": "boolean"}, + "stderr_truncated": map[string]any{"type": "boolean"}, + "timed_out": map[string]any{"type": "boolean"}, + }, + "required": []string{"exit_code", "stdout", "stderr", "duration_ms"}, + }, RequiredScope: model.ScopeServerExec, Handler: handleServerExec, }) diff --git a/cmd/dashboard/controller/mcp_tools_fs.go b/cmd/dashboard/controller/mcp_tools_fs.go index 33d28ec3..6755f349 100644 --- a/cmd/dashboard/controller/mcp_tools_fs.go +++ b/cmd/dashboard/controller/mcp_tools_fs.go @@ -14,6 +14,22 @@ import ( const fsCallTimeout = 30 * time.Second +func fsEntrySchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string"}, + "type": map[string]any{"type": "string"}, + "size": map[string]any{"type": "integer"}, + "mode": map[string]any{"type": "string"}, + "mtime": map[string]any{"type": "integer"}, + "is_symlink": map[string]any{"type": "boolean"}, + "link_target": map[string]any{"type": "string"}, + }, + "required": []string{"name", "type", "size", "mode", "mtime"}, + } +} + func init() { registerMCPTool(&mcpTool{ Name: "fs.list", @@ -27,6 +43,15 @@ func init() { }, "required": []string{"server_id", "path"}, }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "entries": map[string]any{"type": "array", "items": fsEntrySchema()}, + "truncated": map[string]any{"type": "boolean"}, + "total": map[string]any{"type": "integer"}, + }, + "required": []string{"entries"}, + }, RequiredScope: model.ScopeServerRead, Handler: handleFsList, }) @@ -45,6 +70,17 @@ func init() { }, "required": []string{"server_id", "path"}, }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "content": map[string]any{"type": "string"}, + "encoding": map[string]any{"type": "string"}, + "size": map[string]any{"type": "integer"}, + "sha256": map[string]any{"type": "string"}, + "truncated": map[string]any{"type": "boolean"}, + }, + "required": []string{"content", "encoding", "size"}, + }, RequiredScope: model.ScopeServerRead, Handler: handleFsRead, }) @@ -65,6 +101,14 @@ func init() { }, "required": []string{"server_id", "path", "content"}, }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "size": map[string]any{"type": "integer"}, + "sha256": map[string]any{"type": "string"}, + }, + "required": []string{"size", "sha256"}, + }, RequiredScope: model.ScopeServerWrite, Handler: handleFsWrite, }) @@ -81,6 +125,13 @@ func init() { }, "required": []string{"server_id", "path"}, }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "deleted_count": map[string]any{"type": "integer"}, + }, + "required": []string{"deleted_count"}, + }, RequiredScope: model.ScopeServerDelete, Handler: handleFsDelete, }) diff --git a/cmd/dashboard/controller/mcp_tools_meta.go b/cmd/dashboard/controller/mcp_tools_meta.go index 4dd6d0b1..3bac1b99 100644 --- a/cmd/dashboard/controller/mcp_tools_meta.go +++ b/cmd/dashboard/controller/mcp_tools_meta.go @@ -27,6 +27,18 @@ func init() { "type": "object", "properties": map[string]any{}, }, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "user_id": map[string]any{"type": "integer"}, + "is_admin": map[string]any{"type": "boolean"}, + "token_id": map[string]any{"type": "integer"}, + "token_name": map[string]any{"type": "string"}, + "scopes": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "server_ids": map[string]any{"type": "array", "items": map[string]any{"type": "integer"}}, + }, + "required": []string{"user_id", "is_admin", "token_id", "scopes"}, + }, RequiredScope: "", Handler: handleMetaWhoami, }) diff --git a/cmd/dashboard/controller/mcp_tools_server.go b/cmd/dashboard/controller/mcp_tools_server.go index c5c6103a..297603b0 100644 --- a/cmd/dashboard/controller/mcp_tools_server.go +++ b/cmd/dashboard/controller/mcp_tools_server.go @@ -30,6 +30,33 @@ type serverListArgs struct { OnlineOnly bool `json:"online_only,omitempty"` } +// serverListResult 是 server.list 的返回外壳。MCP 2025-06-18 规定 +// structuredContent 必须是 JSON object,不能是裸数组/标量,否则严格客户端 +// (官方 TS/Python SDK)会拒绝整条 tools/call 结果。因此这里把列表包进对象, +// 不再直接返回 []serverListItem。 +type serverListResult struct { + Servers []serverListItem `json:"servers"` + Count int `json:"count"` +} + +func serverListItemSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "integer"}, + "name": map[string]any{"type": "string"}, + "uuid": map[string]any{"type": "string"}, + "ipv4": map[string]any{"type": "string"}, + "ipv6": map[string]any{"type": "string"}, + "online": map[string]any{"type": "boolean"}, + "platform": map[string]any{"type": "string"}, + "arch": map[string]any{"type": "string"}, + "last_active": map[string]any{"type": "string", "format": "date-time"}, + }, + "required": []string{"id", "name", "online"}, + } +} + func init() { registerMCPTool(&mcpTool{ Name: "server.list", @@ -43,14 +70,40 @@ func init() { }, }, }, - RequiredScope: model.ScopeServerRead, + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "servers": map[string]any{ + "type": "array", + "items": serverListItemSchema(), + }, + "count": map[string]any{"type": "integer"}, + }, + "required": []string{"servers", "count"}, + }, + RequiredScope: model.ScopeInventoryRead, Handler: handleServerList, }) registerMCPTool(&mcpTool{ - Name: "server.get", - Description: "Return full Host/State snapshot for a single server.", - InputSchema: serverGetSchema(), + Name: "server.get", + Description: "Return full Host/State snapshot for a single server.", + InputSchema: serverGetSchema(), + OutputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "integer"}, + "name": map[string]any{"type": "string"}, + "uuid": map[string]any{"type": "string"}, + "note": map[string]any{"type": "string"}, + "public_note": map[string]any{"type": "string"}, + "host": map[string]any{"type": "object"}, + "state": map[string]any{"type": "object"}, + "geoip": map[string]any{"type": "object"}, + "last_active": map[string]any{"type": "string", "format": "date-time"}, + }, + "required": []string{"id"}, + }, RequiredScope: model.ScopeServerRead, Handler: handleServerGet, }) @@ -104,7 +157,7 @@ func handleServerList(c *gin.Context, raw json.RawMessage) (any, error) { } out = append(out, item) } - return out, nil + return serverListResult{Servers: out, Count: len(out)}, nil } // server.get diff --git a/cmd/dashboard/controller/mcp_tools_server_test.go b/cmd/dashboard/controller/mcp_tools_server_test.go index 34778d2e..88869b50 100644 --- a/cmd/dashboard/controller/mcp_tools_server_test.go +++ b/cmd/dashboard/controller/mcp_tools_server_test.go @@ -21,7 +21,7 @@ func TestServerList_FiltersByPermission(t *testing.T) { srv2.SetUserID(999) singleton.ServerShared.InsertForTest(srv2) - tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil) + tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil) c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{ JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call", Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}), @@ -30,13 +30,26 @@ func TestServerList_FiltersByPermission(t *testing.T) { _, tcr := decodeRPC(w) require.False(t, tcr.IsError) - rb, _ := json.Marshal(tcr.StructuredContent) - var rows []map[string]any - require.NoError(t, json.Unmarshal(rb, &rows)) + rows := decodeServerListRows(t, tcr.StructuredContent) require.Len(t, rows, 1, "must filter out non-owned server") require.EqualValues(t, 7, rows[0]["id"]) } +// decodeServerListRows unwraps the {servers,count} object that server.list now +// returns (MCP requires structuredContent to be an object, not a bare array) +// and asserts count stays in sync with the servers slice length. +func decodeServerListRows(t *testing.T, structured any) []map[string]any { + t.Helper() + rb, _ := json.Marshal(structured) + var res struct { + Servers []map[string]any `json:"servers"` + Count int `json:"count"` + } + require.NoError(t, json.Unmarshal(rb, &res)) + require.Equal(t, len(res.Servers), res.Count, "count must match servers length") + return res.Servers +} + func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) { cleanup, uid := setupMCPTest(t) defer cleanup() @@ -47,7 +60,7 @@ func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) { srv2.SetUserID(uid) singleton.ServerShared.InsertForTest(srv2) - tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, []uint64{8}) + tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, []uint64{8}) c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{ JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call", Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}), @@ -55,9 +68,7 @@ func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) { mcpEndpoint(c) _, tcr := decodeRPC(w) require.False(t, tcr.IsError) - rb, _ := json.Marshal(tcr.StructuredContent) - var rows []map[string]any - require.NoError(t, json.Unmarshal(rb, &rows)) + rows := decodeServerListRows(t, tcr.StructuredContent) require.Len(t, rows, 1) require.EqualValues(t, 8, rows[0]["id"]) } @@ -70,7 +81,7 @@ func TestServerList_OnlineOnlyFilter(t *testing.T) { require.NotNil(t, srv) srv.LastActive = time.Now() - tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil) + tok, _ := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil) c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{ JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call", Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: jsonRaw(map[string]any{"online_only": true})}), @@ -78,9 +89,7 @@ func TestServerList_OnlineOnlyFilter(t *testing.T) { mcpEndpoint(c) _, tcr := decodeRPC(w) require.False(t, tcr.IsError) - rb, _ := json.Marshal(tcr.StructuredContent) - var rows []map[string]any - require.NoError(t, json.Unmarshal(rb, &rows)) + rows := decodeServerListRows(t, tcr.StructuredContent) require.Len(t, rows, 1) } diff --git a/cmd/dashboard/controller/mcp_transfer.go b/cmd/dashboard/controller/mcp_transfer.go index e144fd0a..ff716755 100644 --- a/cmd/dashboard/controller/mcp_transfer.go +++ b/cmd/dashboard/controller/mcp_transfer.go @@ -245,6 +245,7 @@ func init() { }, "required": []string{"server_id", "path"}, }, + OutputSchema: transferURLOutputSchema(), RequiredScope: model.ScopeServerRead, Handler: handleFsDownloadURL, }) @@ -264,11 +265,24 @@ func init() { }, "required": []string{"server_id", "path"}, }, + OutputSchema: transferURLOutputSchema(), RequiredScope: model.ScopeServerWrite, Handler: handleFsUploadURL, }) } +func transferURLOutputSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "url": map[string]any{"type": "string"}, + "method": map[string]any{"type": "string"}, + "expires_at": map[string]any{"type": "string", "format": "date-time"}, + }, + "required": []string{"url", "method", "expires_at"}, + } +} + func handleFsDownloadURL(c *gin.Context, raw json.RawMessage) (any, error) { var args fsDownloadURLArgs if err := decodeToolArgs(raw, &args); err != nil { diff --git a/cmd/dashboard/controller/rest_scope_test.go b/cmd/dashboard/controller/rest_scope_test.go index f8364d11..75e97767 100644 --- a/cmd/dashboard/controller/rest_scope_test.go +++ b/cmd/dashboard/controller/rest_scope_test.go @@ -19,7 +19,7 @@ func setupRESTScopeTest(t *testing.T) (*httptest.Server, *model.APIToken, string t.Helper() cleanupBase, uid := setupMCPTest(t) - tok, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil) + tok, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil) gin.SetMode(gin.TestMode) r := gin.New() @@ -27,7 +27,7 @@ func setupRESTScopeTest(t *testing.T) (*httptest.Server, *model.APIToken, string r.GET("/server", patMw, - restScopeMiddleware(model.ScopeServerRead), + restScopeMiddleware(model.ScopeInventoryRead), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }, ) r.POST("/server/config", @@ -102,11 +102,13 @@ func TestREST_PATWildcardCoversAllVerbs(t *testing.T) { gin.SetMode(gin.TestMode) r := gin.New() patMw := apiTokenAuthMiddleware() - r.GET("/server", patMw, restScopeMiddleware(model.ScopeServerRead), + r.GET("/server/config/0", patMw, restScopeMiddleware(model.ScopeServerRead), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) r.POST("/server/config", patMw, restScopeMiddleware(model.ScopeServerWrite), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) - r.POST("/batch-delete/server", patMw, restScopeMiddleware(model.ScopeServerDelete), + r.POST("/file", patMw, restScopeMiddleware(model.ScopeServerDelete), + func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) + r.POST("/terminal", patMw, restScopeMiddleware(model.ScopeServerExec), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }) ts := httptest.NewServer(r) defer ts.Close() @@ -114,9 +116,10 @@ func TestREST_PATWildcardCoversAllVerbs(t *testing.T) { for _, tc := range []struct { method, path string }{ - {"GET", "/server"}, + {"GET", "/server/config/0"}, {"POST", "/server/config"}, - {"POST", "/batch-delete/server"}, + {"POST", "/file"}, + {"POST", "/terminal"}, } { resp := doReq(t, ts, tc.method, tc.path, plain) resp.Body.Close() @@ -158,7 +161,7 @@ func TestREST_NoAuthGoesToJWTChain(t *testing.T) { r := gin.New() r.GET("/server", jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt), - restScopeMiddleware(model.ScopeServerRead), + restScopeMiddleware(model.ScopeInventoryRead), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }, ) ts := httptest.NewServer(r) @@ -184,7 +187,7 @@ func TestREST_BadPATShortCircuitsBeforeJWT(t *testing.T) { }) r.GET("/server", jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt), - restScopeMiddleware(model.ScopeServerRead), + restScopeMiddleware(model.ScopeInventoryRead), func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }, ) ts := httptest.NewServer(r) diff --git a/cmd/dashboard/controller/scope_doc.go b/cmd/dashboard/controller/scope_doc.go index b59d2bd1..b7ceeabe 100644 --- a/cmd/dashboard/controller/scope_doc.go +++ b/cmd/dashboard/controller/scope_doc.go @@ -11,10 +11,14 @@ // # Scope naming // // nezha:{resource}:{verb} -// resource: server | service | alertrule | cron | ddns | nat | +// resource: inventory | server | service | alertrule | cron | ddns | nat | // notification | notification-group | transfer | admin // verb: read | write | delete | exec // +// inventory vs server:inventory 管“能看到/能删哪些机器”(列出 server / +// server-group、删除 server / server-group、MCP server.list);server 管对 +// 已知机器的运行态操作(exec / 文件读写 / 编辑配置 / metrics / server.get)。 +// // nezha:* Admin-only superuser // nezha:admin:* Admin-only user/waf/setting/online-user management // nezha::* All actions on a resource @@ -22,7 +26,7 @@ // # MCP tools (POST /mcp tools/call) // // meta.whoami — (any scope) -// server.list nezha:server:read +// server.list nezha:inventory:read // server.get nezha:server:read // server.exec nezha:server:exec // fs.list nezha:server:read @@ -34,22 +38,22 @@ // // # REST endpoints (PAT required scope) // -// GET /api/v1/server nezha:server:read +// GET /api/v1/server nezha:inventory:read // PATCH /api/v1/server/{id} nezha:server:write // GET /api/v1/server/config/{id} nezha:server:write // POST /api/v1/server/config nezha:server:write -// POST /api/v1/batch-delete/server nezha:server:delete +// POST /api/v1/batch-delete/server nezha:inventory:delete // POST /api/v1/batch-move/server nezha:server:write // POST /api/v1/force-update/server nezha:server:write // POST /api/v1/server-group nezha:server:write // PATCH /api/v1/server-group/{id} nezha:server:write -// POST /api/v1/batch-delete/server-group nezha:server:delete +// POST /api/v1/batch-delete/server-group nezha:inventory:delete // POST /api/v1/terminal nezha:server:exec // GET /api/v1/ws/terminal/{id} nezha:server:exec -// POST /api/v1/file nezha:server:write -// GET /api/v1/ws/file/{id} nezha:server:write -// GET /api/v1/ws/server nezha:server:read -// GET /api/v1/server-group nezha:server:read +// POST /api/v1/file nezha:server:read+write+delete +// GET /api/v1/ws/file/{id} nezha:server:read+write+delete +// GET /api/v1/ws/server nezha:inventory:read +// GET /api/v1/server-group nezha:inventory:read // GET /api/v1/service nezha:service:read // GET /api/v1/service/server nezha:service:read // GET /api/v1/service/{id}/history nezha:service:read diff --git a/cmd/dashboard/controller/scope_doc_consistency_test.go b/cmd/dashboard/controller/scope_doc_consistency_test.go index 94321e07..64a0d6fe 100644 --- a/cmd/dashboard/controller/scope_doc_consistency_test.go +++ b/cmd/dashboard/controller/scope_doc_consistency_test.go @@ -32,25 +32,25 @@ type scopedRoute struct { func canonicalRoutes() []scopedRoute { return []scopedRoute{ - {"GET", "/api/v1/server", "nezha:server:read"}, + {"GET", "/api/v1/server", "nezha:inventory:read"}, {"PATCH", "/api/v1/server/{id}", "nezha:server:write"}, {"GET", "/api/v1/server/config/{id}", "nezha:server:write"}, {"POST", "/api/v1/server/config", "nezha:server:write"}, - {"POST", "/api/v1/batch-delete/server", "nezha:server:delete"}, + {"POST", "/api/v1/batch-delete/server", "nezha:inventory:delete"}, {"POST", "/api/v1/batch-move/server", "nezha:server:write"}, {"POST", "/api/v1/force-update/server", "nezha:server:write"}, {"POST", "/api/v1/server-group", "nezha:server:write"}, {"PATCH", "/api/v1/server-group/{id}", "nezha:server:write"}, - {"POST", "/api/v1/batch-delete/server-group", "nezha:server:delete"}, + {"POST", "/api/v1/batch-delete/server-group", "nezha:inventory:delete"}, {"POST", "/api/v1/terminal", "nezha:server:exec"}, {"GET", "/api/v1/ws/terminal/{id}", "nezha:server:exec"}, - {"POST", "/api/v1/file", "nezha:server:write"}, - {"GET", "/api/v1/ws/file/{id}", "nezha:server:write"}, + {"POST", "/api/v1/file", "nezha:server:read+write+delete"}, + {"GET", "/api/v1/ws/file/{id}", "nezha:server:read+write+delete"}, // optional-auth scoped routes(controller.go:91-98)。这些 GET 端点既支持 // 未登录访客,也接受 PAT;当走 PAT 路径时 restScopeMiddleware 会强制对应的 // read scope。漏掉这一段会让 scope_doc.go 与实际 router 漂移而测试不报错。 - {"GET", "/api/v1/ws/server", "nezha:server:read"}, - {"GET", "/api/v1/server-group", "nezha:server:read"}, + {"GET", "/api/v1/ws/server", "nezha:inventory:read"}, + {"GET", "/api/v1/server-group", "nezha:inventory:read"}, {"GET", "/api/v1/service", "nezha:service:read"}, {"GET", "/api/v1/service/server", "nezha:service:read"}, {"GET", "/api/v1/service/{id}/history", "nezha:service:read"}, diff --git a/cmd/dashboard/controller/server.go b/cmd/dashboard/controller/server.go index 14e92801..29c915db 100644 --- a/cmd/dashboard/controller/server.go +++ b/cmd/dashboard/controller/server.go @@ -23,7 +23,7 @@ import ( // @Security BearerAuth // @Security APITokenAuth // @Schemes -// @Description List server. PAT scope required: nezha:server:read. +// @Description List server. PAT scope required: nezha:inventory:read. // @Tags auth required // @Param id query uint false "Resource ID" // @Produce json diff --git a/model/api_token.go b/model/api_token.go index 18e7be94..4b773eb5 100644 --- a/model/api_token.go +++ b/model/api_token.go @@ -12,7 +12,7 @@ import ( // Scope 命名规范(唯一一套):nezha:{resource}:{verb} // -// - resource: server / service / alertrule / cron / ddns / nat / +// - resource: inventory / server / service / alertrule / cron / ddns / nat / // notification / notification-group / transfer / admin // - verb: read / write / delete / exec // @@ -20,8 +20,10 @@ import ( // - nezha:server:* 给定资源的所有动作 // - nezha:* admin-only 全权 // -// 同一 scope 同时管 MCP tool 和 REST endpoint:例如 nezha:server:read 既允许 -// `server.list` MCP tool,也允许 `GET /api/v1/server`。 +// inventory 与 server 已拆开:inventory 管“能看到/能删哪些机器”——`server.list` +// MCP tool、`GET /api/v1/server`、`/server-group`、batch-delete server/group 都用 +// nezha:inventory:{read,delete};server 管对已知机器的运行态操作(server.get、 +// exec、文件读写、编辑配置、metrics)。同一 scope 同时管 MCP tool 和 REST endpoint。 // // 历史上还有 mcp:* 一套,会被 HasScope 通过别名映射到 nezha:server:* 子集。 // 由于 HasScope 同时服务 MCP tool 调度与 REST scope middleware,旧 mcp:fs:write @@ -33,6 +35,14 @@ import ( const ( ScopeNezhaAll = "nezha:*" + // inventory 资源域:管理后台对“服务器清单”本身的枚举与删除(列出 GET /server、 + // 删除 batch-delete/server、server-group 的列出/删除,以及 MCP server.list)。 + // 刻意与 nezha:server:* 分开:后者是对已知 server 的运行态操作(exec / 文件读写 / + // 编辑 / metrics),而 inventory 是“能看到/能删哪些机器”的台账权限。拆开后, + // 一张只跑命令的 PAT 不必同时具备遍历和删除整个清单的能力。 + ScopeInventoryRead = "nezha:inventory:read" + ScopeInventoryDelete = "nezha:inventory:delete" + ScopeServerRead = "nezha:server:read" ScopeServerWrite = "nezha:server:write" ScopeServerDelete = "nezha:server:delete" @@ -75,6 +85,7 @@ const ( ) var AllScopes = []string{ + ScopeInventoryRead, ScopeInventoryDelete, ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec, ScopeServiceRead, ScopeServiceWrite, ScopeServiceDelete, ScopeAlertRuleRead, ScopeAlertRuleWrite, ScopeAlertRuleDelete, @@ -85,6 +96,7 @@ var AllScopes = []string{ ScopeNotificationGroupRead, ScopeNotificationGroupWrite, ScopeNotificationGroupDelete, ScopeTransferRead, ScopeTransferWrite, ScopeTransferDelete, + "nezha:inventory:*", "nezha:server:*", "nezha:service:*", "nezha:alertrule:*", @@ -124,17 +136,17 @@ const APITokenPrefix = "nzp_" // APIToken 是用户用于程序化访问的长期凭证。MCP 接入点 /mcp 用它做鉴权。 // 双层鉴权:闸 1 用 UserID 复用 Server.HasPermission;闸 2 用 Scopes / ServerIDs。 type APIToken struct { - ID uint64 `gorm:"primaryKey" json:"id,omitempty"` - UserID uint64 `gorm:"index" json:"user_id,omitempty"` - Name string `gorm:"type:varchar(128)" json:"name,omitempty"` - TokenHash string `gorm:"uniqueIndex;type:char(64)" json:"-"` - ScopesCSV string `gorm:"type:text" json:"-"` - ServersCSV string `gorm:"type:text" json:"-"` + ID uint64 `gorm:"primaryKey" json:"id,omitempty"` + UserID uint64 `gorm:"index" json:"user_id,omitempty"` + Name string `gorm:"type:varchar(128)" json:"name,omitempty"` + TokenHash string `gorm:"uniqueIndex;type:char(64)" json:"-"` + ScopesCSV string `gorm:"type:text" json:"-"` + ServersCSV string `gorm:"type:text" json:"-"` ExpiresAt *time.Time `gorm:"index" json:"expires_at,omitempty"` LastUsedAt *time.Time `json:"last_used_at,omitempty"` - LastUsedIP string `gorm:"type:varchar(64)" json:"last_used_ip,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"` + LastUsedIP string `gorm:"type:varchar(64)" json:"last_used_ip,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"` } func (APIToken) TableName() string {