mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
feat(auth): split inventory scope out of server scope
Carve a new nezha:inventory:{read,delete,*} resource family out of
nezha:server:*. Listing and deleting servers/server-groups (GET /server,
/server-group, /ws/server, batch-delete/server[-group], MCP server.list)
now require nezha:inventory:*, while nezha:server:* covers per-server
runtime operations (get, exec, fs, config, metrics, batch-move,
force-update).
Also declare MCP tool OutputSchema for exec/fs/meta/server/transfer and
wrap server.list output in {servers,count} so strict MCP clients accept
the structured result. Correct the /file all-of scope entry and the stale
server:read documentation.
This commit is contained in:
@@ -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}) }
|
stub := func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) }
|
||||||
optionalAuth := r.Group("/api/v1", authMw)
|
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("/service", restScopeMiddleware(model.ScopeServiceRead), stub)
|
||||||
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), stub)
|
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), stub)
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ func TestOptionalAuth_PATWithMatchingScopeAllowed(t *testing.T) {
|
|||||||
cleanup, uid := setupMCPTest(t)
|
cleanup, uid := setupMCPTest(t)
|
||||||
defer cleanup()
|
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)
|
ts := setupOptionalAuthRouter(t, plain)
|
||||||
|
|
||||||
for _, path := range []string{
|
for _, path := range []string{
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ func setupRESTScopeServer(t *testing.T) (*httptest.Server, string, func()) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
cleanupBase, uid := setupMCPTest(t)
|
cleanupBase, uid := setupMCPTest(t)
|
||||||
|
|
||||||
_, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
pat := apiTokenAuthMiddleware()
|
pat := apiTokenAuthMiddleware()
|
||||||
r.GET("/api/v1/server",
|
r.GET("/api/v1/server",
|
||||||
pat,
|
pat,
|
||||||
restScopeMiddleware(model.ScopeServerRead),
|
restScopeMiddleware(model.ScopeInventoryRead),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||||
)
|
)
|
||||||
r.POST("/api/v1/server/config",
|
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.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: uid}, Role: model.RoleMember})
|
||||||
c.Next()
|
c.Next()
|
||||||
},
|
},
|
||||||
restScopeMiddleware(model.ScopeServerRead),
|
restScopeMiddleware(model.ScopeInventoryRead),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||||
)
|
)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
@@ -148,7 +148,7 @@ func TestRESTScope_NezhaAllUnlocksEverything(t *testing.T) {
|
|||||||
)
|
)
|
||||||
r.POST("/api/v1/batch-delete/server",
|
r.POST("/api/v1/batch-delete/server",
|
||||||
pat,
|
pat,
|
||||||
restScopeMiddleware(model.ScopeServerDelete),
|
restScopeMiddleware(model.ScopeInventoryDelete),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||||
)
|
)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
@@ -193,7 +193,7 @@ func TestRESTScope_GoodPATClearsWAFCounter(t *testing.T) {
|
|||||||
cleanupBase, uid := setupMCPTest(t)
|
cleanupBase, uid := setupMCPTest(t)
|
||||||
defer cleanupBase()
|
defer cleanupBase()
|
||||||
|
|
||||||
_, plain := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
_, plain := mkToken(t, uid, []string{model.ScopeInventoryRead}, nil)
|
||||||
|
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -203,7 +203,7 @@ func TestRESTScope_GoodPATClearsWAFCounter(t *testing.T) {
|
|||||||
})
|
})
|
||||||
r.GET("/api/v1/server",
|
r.GET("/api/v1/server",
|
||||||
apiTokenAuthMiddleware(),
|
apiTokenAuthMiddleware(),
|
||||||
restScopeMiddleware(model.ScopeServerRead),
|
restScopeMiddleware(model.ScopeInventoryRead),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||||
)
|
)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
|
|||||||
@@ -88,8 +88,8 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
|||||||
optionalAuthMw := utils.IfOr(singleton.Conf.ForceAuth, authMw, patOrFallbackAuthMiddleware(patMw, fallbackAuthMw))
|
optionalAuthMw := utils.IfOr(singleton.Conf.ForceAuth, authMw, patOrFallbackAuthMiddleware(patMw, fallbackAuthMw))
|
||||||
|
|
||||||
optionalAuth := api.Group("", optionalAuthMw)
|
optionalAuth := api.Group("", optionalAuthMw)
|
||||||
optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeServerRead), commonHandler(serverStream))
|
optionalAuth.GET("/ws/server", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(serverStream))
|
||||||
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeServerRead), commonHandler(listServerGroup))
|
optionalAuth.GET("/server-group", restScopeMiddleware(model.ScopeInventoryRead), commonHandler(listServerGroup))
|
||||||
|
|
||||||
optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(showService))
|
optionalAuth.GET("/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(showService))
|
||||||
optionalAuth.GET("/service/server", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerWithServices))
|
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.POST("/api-tokens", patForbidden, commonHandler(createAPIToken))
|
||||||
auth.DELETE("/api-tokens/:id", patForbidden, commonHandler(deleteAPIToken))
|
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.POST("/terminal", restScopeMiddleware(model.ScopeServerExec), commonHandler(createTerminal))
|
||||||
auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream))
|
auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream))
|
||||||
auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM))
|
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("/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.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer))
|
||||||
auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig))
|
auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig))
|
||||||
auth.POST("/server/config", restScopeMiddleware(model.ScopeServerWrite), commonHandler(setServerConfig))
|
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("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer))
|
||||||
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
|
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
|
||||||
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
|
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
|
||||||
auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup))
|
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)。
|
// transfer — 严格使用 nezha:transfer 资源族 scope(read/write/delete)。
|
||||||
// 注意:曾经计划让 nezha:server:read 兼听只读 transfer,但 restScopeMiddleware
|
// 注意:曾经计划让 nezha:server:read 兼听只读 transfer,但 restScopeMiddleware
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type mcpToolDescriptor struct {
|
|||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
InputSchema map[string]any `json:"inputSchema"`
|
InputSchema map[string]any `json:"inputSchema"`
|
||||||
|
OutputSchema map[string]any `json:"outputSchema,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// mcpToolsListResult tools/list 响应。
|
// mcpToolsListResult tools/list 响应。
|
||||||
@@ -119,6 +120,7 @@ type mcpTool struct {
|
|||||||
Name string
|
Name string
|
||||||
Description string
|
Description string
|
||||||
InputSchema map[string]any
|
InputSchema map[string]any
|
||||||
|
OutputSchema map[string]any // 可选;声明 structuredContent 形状,供严格客户端校验
|
||||||
RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami)
|
RequiredScope string // 闸 2 入口;空字符串 = 任意 PAT 都能调(如 meta.whoami)
|
||||||
Handler mcpToolHandler
|
Handler mcpToolHandler
|
||||||
}
|
}
|
||||||
@@ -256,6 +258,7 @@ func buildToolDescriptors() []mcpToolDescriptor {
|
|||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
InputSchema: t.InputSchema,
|
InputSchema: t.InputSchema,
|
||||||
|
OutputSchema: t.OutputSchema,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -228,9 +228,10 @@ func setupEndToEnd(t *testing.T) (*httptest.Server, string, func()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, plain := mkToken(t, uid, []string{
|
_, plain := mkToken(t, uid, []string{
|
||||||
|
model.ScopeInventoryRead,
|
||||||
|
model.ScopeInventoryDelete,
|
||||||
model.ScopeServerRead,
|
model.ScopeServerRead,
|
||||||
model.ScopeServerExec,
|
model.ScopeServerExec,
|
||||||
model.ScopeServerRead,
|
|
||||||
model.ScopeServerWrite,
|
model.ScopeServerWrite,
|
||||||
model.ScopeServerDelete,
|
model.ScopeServerDelete,
|
||||||
}, nil)
|
}, nil)
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ func setupSDKCompat(t *testing.T) (string, string, func()) {
|
|||||||
srv.SetTaskStream(&e2eStream{dispatch: agentSim})
|
srv.SetTaskStream(&e2eStream{dispatch: agentSim})
|
||||||
|
|
||||||
_, plain := mkToken(t, uid, []string{
|
_, plain := mkToken(t, uid, []string{
|
||||||
|
model.ScopeInventoryRead,
|
||||||
|
model.ScopeInventoryDelete,
|
||||||
model.ScopeServerRead,
|
model.ScopeServerRead,
|
||||||
model.ScopeServerWrite,
|
model.ScopeServerWrite,
|
||||||
model.ScopeServerDelete,
|
model.ScopeServerDelete,
|
||||||
|
|||||||
@@ -51,6 +51,19 @@ func init() {
|
|||||||
},
|
},
|
||||||
"required": []string{"server_id", "cmd"},
|
"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,
|
RequiredScope: model.ScopeServerExec,
|
||||||
Handler: handleServerExec,
|
Handler: handleServerExec,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -14,6 +14,22 @@ import (
|
|||||||
|
|
||||||
const fsCallTimeout = 30 * time.Second
|
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() {
|
func init() {
|
||||||
registerMCPTool(&mcpTool{
|
registerMCPTool(&mcpTool{
|
||||||
Name: "fs.list",
|
Name: "fs.list",
|
||||||
@@ -27,6 +43,15 @@ func init() {
|
|||||||
},
|
},
|
||||||
"required": []string{"server_id", "path"},
|
"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,
|
RequiredScope: model.ScopeServerRead,
|
||||||
Handler: handleFsList,
|
Handler: handleFsList,
|
||||||
})
|
})
|
||||||
@@ -45,6 +70,17 @@ func init() {
|
|||||||
},
|
},
|
||||||
"required": []string{"server_id", "path"},
|
"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,
|
RequiredScope: model.ScopeServerRead,
|
||||||
Handler: handleFsRead,
|
Handler: handleFsRead,
|
||||||
})
|
})
|
||||||
@@ -65,6 +101,14 @@ func init() {
|
|||||||
},
|
},
|
||||||
"required": []string{"server_id", "path", "content"},
|
"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,
|
RequiredScope: model.ScopeServerWrite,
|
||||||
Handler: handleFsWrite,
|
Handler: handleFsWrite,
|
||||||
})
|
})
|
||||||
@@ -81,6 +125,13 @@ func init() {
|
|||||||
},
|
},
|
||||||
"required": []string{"server_id", "path"},
|
"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,
|
RequiredScope: model.ScopeServerDelete,
|
||||||
Handler: handleFsDelete,
|
Handler: handleFsDelete,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -27,6 +27,18 @@ func init() {
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": map[string]any{},
|
"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: "",
|
RequiredScope: "",
|
||||||
Handler: handleMetaWhoami,
|
Handler: handleMetaWhoami,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -30,6 +30,33 @@ type serverListArgs struct {
|
|||||||
OnlineOnly bool `json:"online_only,omitempty"`
|
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() {
|
func init() {
|
||||||
registerMCPTool(&mcpTool{
|
registerMCPTool(&mcpTool{
|
||||||
Name: "server.list",
|
Name: "server.list",
|
||||||
@@ -43,7 +70,18 @@ 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,
|
Handler: handleServerList,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,6 +89,21 @@ func init() {
|
|||||||
Name: "server.get",
|
Name: "server.get",
|
||||||
Description: "Return full Host/State snapshot for a single server.",
|
Description: "Return full Host/State snapshot for a single server.",
|
||||||
InputSchema: serverGetSchema(),
|
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,
|
RequiredScope: model.ScopeServerRead,
|
||||||
Handler: handleServerGet,
|
Handler: handleServerGet,
|
||||||
})
|
})
|
||||||
@@ -104,7 +157,7 @@ func handleServerList(c *gin.Context, raw json.RawMessage) (any, error) {
|
|||||||
}
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
}
|
}
|
||||||
return out, nil
|
return serverListResult{Servers: out, Count: len(out)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// server.get
|
// server.get
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestServerList_FiltersByPermission(t *testing.T) {
|
|||||||
srv2.SetUserID(999)
|
srv2.SetUserID(999)
|
||||||
singleton.ServerShared.InsertForTest(srv2)
|
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{
|
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
|
||||||
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
||||||
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
|
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
|
||||||
@@ -30,13 +30,26 @@ func TestServerList_FiltersByPermission(t *testing.T) {
|
|||||||
_, tcr := decodeRPC(w)
|
_, tcr := decodeRPC(w)
|
||||||
require.False(t, tcr.IsError)
|
require.False(t, tcr.IsError)
|
||||||
|
|
||||||
rb, _ := json.Marshal(tcr.StructuredContent)
|
rows := decodeServerListRows(t, tcr.StructuredContent)
|
||||||
var rows []map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(rb, &rows))
|
|
||||||
require.Len(t, rows, 1, "must filter out non-owned server")
|
require.Len(t, rows, 1, "must filter out non-owned server")
|
||||||
require.EqualValues(t, 7, rows[0]["id"])
|
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) {
|
func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) {
|
||||||
cleanup, uid := setupMCPTest(t)
|
cleanup, uid := setupMCPTest(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -47,7 +60,7 @@ func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) {
|
|||||||
srv2.SetUserID(uid)
|
srv2.SetUserID(uid)
|
||||||
singleton.ServerShared.InsertForTest(srv2)
|
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{
|
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
|
||||||
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
||||||
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
|
Params: jsonObj(t, toolCallParams{Name: "server.list", Arguments: json.RawMessage("{}")}),
|
||||||
@@ -55,9 +68,7 @@ func TestServerList_ServerWhitelistFurtherFiltering(t *testing.T) {
|
|||||||
mcpEndpoint(c)
|
mcpEndpoint(c)
|
||||||
_, tcr := decodeRPC(w)
|
_, tcr := decodeRPC(w)
|
||||||
require.False(t, tcr.IsError)
|
require.False(t, tcr.IsError)
|
||||||
rb, _ := json.Marshal(tcr.StructuredContent)
|
rows := decodeServerListRows(t, tcr.StructuredContent)
|
||||||
var rows []map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(rb, &rows))
|
|
||||||
require.Len(t, rows, 1)
|
require.Len(t, rows, 1)
|
||||||
require.EqualValues(t, 8, rows[0]["id"])
|
require.EqualValues(t, 8, rows[0]["id"])
|
||||||
}
|
}
|
||||||
@@ -70,7 +81,7 @@ func TestServerList_OnlineOnlyFilter(t *testing.T) {
|
|||||||
require.NotNil(t, srv)
|
require.NotNil(t, srv)
|
||||||
srv.LastActive = time.Now()
|
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{
|
c, w := mcpCallCtx(t, tok, uid, jsonRPCRequest{
|
||||||
JSONRPC: "2.0", ID: json.RawMessage("1"), Method: "tools/call",
|
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})}),
|
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)
|
mcpEndpoint(c)
|
||||||
_, tcr := decodeRPC(w)
|
_, tcr := decodeRPC(w)
|
||||||
require.False(t, tcr.IsError)
|
require.False(t, tcr.IsError)
|
||||||
rb, _ := json.Marshal(tcr.StructuredContent)
|
rows := decodeServerListRows(t, tcr.StructuredContent)
|
||||||
var rows []map[string]any
|
|
||||||
require.NoError(t, json.Unmarshal(rb, &rows))
|
|
||||||
require.Len(t, rows, 1)
|
require.Len(t, rows, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -245,6 +245,7 @@ func init() {
|
|||||||
},
|
},
|
||||||
"required": []string{"server_id", "path"},
|
"required": []string{"server_id", "path"},
|
||||||
},
|
},
|
||||||
|
OutputSchema: transferURLOutputSchema(),
|
||||||
RequiredScope: model.ScopeServerRead,
|
RequiredScope: model.ScopeServerRead,
|
||||||
Handler: handleFsDownloadURL,
|
Handler: handleFsDownloadURL,
|
||||||
})
|
})
|
||||||
@@ -264,11 +265,24 @@ func init() {
|
|||||||
},
|
},
|
||||||
"required": []string{"server_id", "path"},
|
"required": []string{"server_id", "path"},
|
||||||
},
|
},
|
||||||
|
OutputSchema: transferURLOutputSchema(),
|
||||||
RequiredScope: model.ScopeServerWrite,
|
RequiredScope: model.ScopeServerWrite,
|
||||||
Handler: handleFsUploadURL,
|
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) {
|
func handleFsDownloadURL(c *gin.Context, raw json.RawMessage) (any, error) {
|
||||||
var args fsDownloadURLArgs
|
var args fsDownloadURLArgs
|
||||||
if err := decodeToolArgs(raw, &args); err != nil {
|
if err := decodeToolArgs(raw, &args); err != nil {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func setupRESTScopeTest(t *testing.T) (*httptest.Server, *model.APIToken, string
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
cleanupBase, uid := setupMCPTest(t)
|
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)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
@@ -27,7 +27,7 @@ func setupRESTScopeTest(t *testing.T) (*httptest.Server, *model.APIToken, string
|
|||||||
|
|
||||||
r.GET("/server",
|
r.GET("/server",
|
||||||
patMw,
|
patMw,
|
||||||
restScopeMiddleware(model.ScopeServerRead),
|
restScopeMiddleware(model.ScopeInventoryRead),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||||
)
|
)
|
||||||
r.POST("/server/config",
|
r.POST("/server/config",
|
||||||
@@ -102,11 +102,13 @@ func TestREST_PATWildcardCoversAllVerbs(t *testing.T) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
patMw := apiTokenAuthMiddleware()
|
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}) })
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||||
r.POST("/server/config", patMw, restScopeMiddleware(model.ScopeServerWrite),
|
r.POST("/server/config", patMw, restScopeMiddleware(model.ScopeServerWrite),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
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}) })
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) })
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
defer ts.Close()
|
defer ts.Close()
|
||||||
@@ -114,9 +116,10 @@ func TestREST_PATWildcardCoversAllVerbs(t *testing.T) {
|
|||||||
for _, tc := range []struct {
|
for _, tc := range []struct {
|
||||||
method, path string
|
method, path string
|
||||||
}{
|
}{
|
||||||
{"GET", "/server"},
|
{"GET", "/server/config/0"},
|
||||||
{"POST", "/server/config"},
|
{"POST", "/server/config"},
|
||||||
{"POST", "/batch-delete/server"},
|
{"POST", "/file"},
|
||||||
|
{"POST", "/terminal"},
|
||||||
} {
|
} {
|
||||||
resp := doReq(t, ts, tc.method, tc.path, plain)
|
resp := doReq(t, ts, tc.method, tc.path, plain)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
@@ -158,7 +161,7 @@ func TestREST_NoAuthGoesToJWTChain(t *testing.T) {
|
|||||||
r := gin.New()
|
r := gin.New()
|
||||||
r.GET("/server",
|
r.GET("/server",
|
||||||
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
||||||
restScopeMiddleware(model.ScopeServerRead),
|
restScopeMiddleware(model.ScopeInventoryRead),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||||
)
|
)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
@@ -184,7 +187,7 @@ func TestREST_BadPATShortCircuitsBeforeJWT(t *testing.T) {
|
|||||||
})
|
})
|
||||||
r.GET("/server",
|
r.GET("/server",
|
||||||
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
jwtOrPATAuthMiddleware(apiTokenAuthMiddleware(), fakeJwt),
|
||||||
restScopeMiddleware(model.ScopeServerRead),
|
restScopeMiddleware(model.ScopeInventoryRead),
|
||||||
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
func(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) },
|
||||||
)
|
)
|
||||||
ts := httptest.NewServer(r)
|
ts := httptest.NewServer(r)
|
||||||
|
|||||||
@@ -11,10 +11,14 @@
|
|||||||
// # Scope naming
|
// # Scope naming
|
||||||
//
|
//
|
||||||
// nezha:{resource}:{verb}
|
// nezha:{resource}:{verb}
|
||||||
// resource: server | service | alertrule | cron | ddns | nat |
|
// resource: inventory | server | service | alertrule | cron | ddns | nat |
|
||||||
// notification | notification-group | transfer | admin
|
// notification | notification-group | transfer | admin
|
||||||
// verb: read | write | delete | exec
|
// 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-only superuser
|
||||||
// nezha:admin:* Admin-only user/waf/setting/online-user management
|
// nezha:admin:* Admin-only user/waf/setting/online-user management
|
||||||
// nezha:<res>:* All actions on a resource
|
// nezha:<res>:* All actions on a resource
|
||||||
@@ -22,7 +26,7 @@
|
|||||||
// # MCP tools (POST /mcp tools/call)
|
// # MCP tools (POST /mcp tools/call)
|
||||||
//
|
//
|
||||||
// meta.whoami — (any scope)
|
// meta.whoami — (any scope)
|
||||||
// server.list nezha:server:read
|
// server.list nezha:inventory:read
|
||||||
// server.get nezha:server:read
|
// server.get nezha:server:read
|
||||||
// server.exec nezha:server:exec
|
// server.exec nezha:server:exec
|
||||||
// fs.list nezha:server:read
|
// fs.list nezha:server:read
|
||||||
@@ -34,22 +38,22 @@
|
|||||||
//
|
//
|
||||||
// # REST endpoints (PAT required scope)
|
// # 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
|
// PATCH /api/v1/server/{id} nezha:server:write
|
||||||
// GET /api/v1/server/config/{id} nezha:server:write
|
// GET /api/v1/server/config/{id} nezha:server:write
|
||||||
// POST /api/v1/server/config 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/batch-move/server nezha:server:write
|
||||||
// POST /api/v1/force-update/server nezha:server:write
|
// POST /api/v1/force-update/server nezha:server:write
|
||||||
// POST /api/v1/server-group nezha:server:write
|
// POST /api/v1/server-group nezha:server:write
|
||||||
// PATCH /api/v1/server-group/{id} 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
|
// POST /api/v1/terminal nezha:server:exec
|
||||||
// GET /api/v1/ws/terminal/{id} nezha:server:exec
|
// GET /api/v1/ws/terminal/{id} nezha:server:exec
|
||||||
// POST /api/v1/file nezha:server:write
|
// POST /api/v1/file nezha:server:read+write+delete
|
||||||
// GET /api/v1/ws/file/{id} nezha:server:write
|
// GET /api/v1/ws/file/{id} nezha:server:read+write+delete
|
||||||
// GET /api/v1/ws/server nezha:server:read
|
// GET /api/v1/ws/server nezha:inventory:read
|
||||||
// GET /api/v1/server-group nezha:server:read
|
// GET /api/v1/server-group nezha:inventory:read
|
||||||
// GET /api/v1/service nezha:service:read
|
// GET /api/v1/service nezha:service:read
|
||||||
// GET /api/v1/service/server nezha:service:read
|
// GET /api/v1/service/server nezha:service:read
|
||||||
// GET /api/v1/service/{id}/history nezha:service:read
|
// GET /api/v1/service/{id}/history nezha:service:read
|
||||||
|
|||||||
@@ -32,25 +32,25 @@ type scopedRoute struct {
|
|||||||
|
|
||||||
func canonicalRoutes() []scopedRoute {
|
func canonicalRoutes() []scopedRoute {
|
||||||
return []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"},
|
{"PATCH", "/api/v1/server/{id}", "nezha:server:write"},
|
||||||
{"GET", "/api/v1/server/config/{id}", "nezha:server:write"},
|
{"GET", "/api/v1/server/config/{id}", "nezha:server:write"},
|
||||||
{"POST", "/api/v1/server/config", "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/batch-move/server", "nezha:server:write"},
|
||||||
{"POST", "/api/v1/force-update/server", "nezha:server:write"},
|
{"POST", "/api/v1/force-update/server", "nezha:server:write"},
|
||||||
{"POST", "/api/v1/server-group", "nezha:server:write"},
|
{"POST", "/api/v1/server-group", "nezha:server:write"},
|
||||||
{"PATCH", "/api/v1/server-group/{id}", "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"},
|
{"POST", "/api/v1/terminal", "nezha:server:exec"},
|
||||||
{"GET", "/api/v1/ws/terminal/{id}", "nezha:server:exec"},
|
{"GET", "/api/v1/ws/terminal/{id}", "nezha:server:exec"},
|
||||||
{"POST", "/api/v1/file", "nezha:server:write"},
|
{"POST", "/api/v1/file", "nezha:server:read+write+delete"},
|
||||||
{"GET", "/api/v1/ws/file/{id}", "nezha:server:write"},
|
{"GET", "/api/v1/ws/file/{id}", "nezha:server:read+write+delete"},
|
||||||
// optional-auth scoped routes(controller.go:91-98)。这些 GET 端点既支持
|
// optional-auth scoped routes(controller.go:91-98)。这些 GET 端点既支持
|
||||||
// 未登录访客,也接受 PAT;当走 PAT 路径时 restScopeMiddleware 会强制对应的
|
// 未登录访客,也接受 PAT;当走 PAT 路径时 restScopeMiddleware 会强制对应的
|
||||||
// read scope。漏掉这一段会让 scope_doc.go 与实际 router 漂移而测试不报错。
|
// read scope。漏掉这一段会让 scope_doc.go 与实际 router 漂移而测试不报错。
|
||||||
{"GET", "/api/v1/ws/server", "nezha:server:read"},
|
{"GET", "/api/v1/ws/server", "nezha:inventory:read"},
|
||||||
{"GET", "/api/v1/server-group", "nezha:server:read"},
|
{"GET", "/api/v1/server-group", "nezha:inventory:read"},
|
||||||
{"GET", "/api/v1/service", "nezha:service:read"},
|
{"GET", "/api/v1/service", "nezha:service:read"},
|
||||||
{"GET", "/api/v1/service/server", "nezha:service:read"},
|
{"GET", "/api/v1/service/server", "nezha:service:read"},
|
||||||
{"GET", "/api/v1/service/{id}/history", "nezha:service:read"},
|
{"GET", "/api/v1/service/{id}/history", "nezha:service:read"},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
// @Security APITokenAuth
|
// @Security APITokenAuth
|
||||||
// @Schemes
|
// @Schemes
|
||||||
// @Description List server. PAT scope required: nezha:server:read.
|
// @Description List server. PAT scope required: nezha:inventory:read.
|
||||||
// @Tags auth required
|
// @Tags auth required
|
||||||
// @Param id query uint false "Resource ID"
|
// @Param id query uint false "Resource ID"
|
||||||
// @Produce json
|
// @Produce json
|
||||||
|
|||||||
+15
-3
@@ -12,7 +12,7 @@ import (
|
|||||||
|
|
||||||
// Scope 命名规范(唯一一套):nezha:{resource}:{verb}
|
// Scope 命名规范(唯一一套):nezha:{resource}:{verb}
|
||||||
//
|
//
|
||||||
// - resource: server / service / alertrule / cron / ddns / nat /
|
// - resource: inventory / server / service / alertrule / cron / ddns / nat /
|
||||||
// notification / notification-group / transfer / admin
|
// notification / notification-group / transfer / admin
|
||||||
// - verb: read / write / delete / exec
|
// - verb: read / write / delete / exec
|
||||||
//
|
//
|
||||||
@@ -20,8 +20,10 @@ import (
|
|||||||
// - nezha:server:* 给定资源的所有动作
|
// - nezha:server:* 给定资源的所有动作
|
||||||
// - nezha:* admin-only 全权
|
// - nezha:* admin-only 全权
|
||||||
//
|
//
|
||||||
// 同一 scope 同时管 MCP tool 和 REST endpoint:例如 nezha:server:read 既允许
|
// inventory 与 server 已拆开:inventory 管“能看到/能删哪些机器”——`server.list`
|
||||||
// `server.list` MCP tool,也允许 `GET /api/v1/server`。
|
// 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:* 子集。
|
// 历史上还有 mcp:* 一套,会被 HasScope 通过别名映射到 nezha:server:* 子集。
|
||||||
// 由于 HasScope 同时服务 MCP tool 调度与 REST scope middleware,旧 mcp:fs:write
|
// 由于 HasScope 同时服务 MCP tool 调度与 REST scope middleware,旧 mcp:fs:write
|
||||||
@@ -33,6 +35,14 @@ import (
|
|||||||
const (
|
const (
|
||||||
ScopeNezhaAll = "nezha:*"
|
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"
|
ScopeServerRead = "nezha:server:read"
|
||||||
ScopeServerWrite = "nezha:server:write"
|
ScopeServerWrite = "nezha:server:write"
|
||||||
ScopeServerDelete = "nezha:server:delete"
|
ScopeServerDelete = "nezha:server:delete"
|
||||||
@@ -75,6 +85,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var AllScopes = []string{
|
var AllScopes = []string{
|
||||||
|
ScopeInventoryRead, ScopeInventoryDelete,
|
||||||
ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec,
|
ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec,
|
||||||
ScopeServiceRead, ScopeServiceWrite, ScopeServiceDelete,
|
ScopeServiceRead, ScopeServiceWrite, ScopeServiceDelete,
|
||||||
ScopeAlertRuleRead, ScopeAlertRuleWrite, ScopeAlertRuleDelete,
|
ScopeAlertRuleRead, ScopeAlertRuleWrite, ScopeAlertRuleDelete,
|
||||||
@@ -85,6 +96,7 @@ var AllScopes = []string{
|
|||||||
ScopeNotificationGroupRead, ScopeNotificationGroupWrite, ScopeNotificationGroupDelete,
|
ScopeNotificationGroupRead, ScopeNotificationGroupWrite, ScopeNotificationGroupDelete,
|
||||||
ScopeTransferRead, ScopeTransferWrite, ScopeTransferDelete,
|
ScopeTransferRead, ScopeTransferWrite, ScopeTransferDelete,
|
||||||
|
|
||||||
|
"nezha:inventory:*",
|
||||||
"nezha:server:*",
|
"nezha:server:*",
|
||||||
"nezha:service:*",
|
"nezha:service:*",
|
||||||
"nezha:alertrule:*",
|
"nezha:alertrule:*",
|
||||||
|
|||||||
Reference in New Issue
Block a user