mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 09:40:12 +00:00
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>
255 lines
6.8 KiB
Go
255 lines
6.8 KiB
Go
package controller
|
|
|
|
import (
|
|
"slices"
|
|
"strconv"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/jinzhu/copier"
|
|
|
|
"github.com/nezhahq/nezha/model"
|
|
"github.com/nezhahq/nezha/service/singleton"
|
|
)
|
|
|
|
// List schedule tasks
|
|
// @Summary List schedule tasks
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description List schedule tasks
|
|
// @Tags auth required
|
|
// @Param id query uint false "Resource ID"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[[]model.Cron]
|
|
// @Router /cron [get]
|
|
func listCron(c *gin.Context) ([]*model.Cron, error) {
|
|
slist := singleton.CronShared.GetSortedList()
|
|
|
|
var cr []*model.Cron
|
|
if err := copier.Copy(&cr, &slist); err != nil {
|
|
return nil, err
|
|
}
|
|
return cr, nil
|
|
}
|
|
|
|
// Create new schedule task
|
|
// @Summary Create new schedule task
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Create new schedule task
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @param request body model.CronForm true "CronForm"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[uint64]
|
|
// @Router /cron [post]
|
|
func createCron(c *gin.Context) (uint64, error) {
|
|
var cf model.CronForm
|
|
var cr model.Cron
|
|
|
|
if err := c.ShouldBindJSON(&cf); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if !isValidCronCover(cf.Cover) {
|
|
return 0, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
if err := checkCronServerListPermission(c, cf.Cover, cf.Servers, getUid(c)); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if err := rejectImplicitCoverForLimitedPAT(c, cf.Cover, cf.Servers); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if err := assertOwnsNotificationGroup(c, cf.NotificationGroupID); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
cr.UserID = getUid(c)
|
|
cr.TaskType = cf.TaskType
|
|
cr.Name = cf.Name
|
|
cr.Scheduler = cf.Scheduler
|
|
cr.Command = cf.Command
|
|
cr.Servers = cf.Servers
|
|
cr.PushSuccessful = cf.PushSuccessful
|
|
cr.NotificationGroupID = cf.NotificationGroupID
|
|
cr.Cover = cf.Cover
|
|
|
|
if cr.TaskType == model.CronTypeCronTask && cr.Cover == model.CronCoverAlertTrigger {
|
|
return 0, singleton.Localizer.ErrorT("scheduled tasks cannot be triggered by alarms")
|
|
}
|
|
|
|
var err error
|
|
if cf.TaskType == model.CronTypeCronTask {
|
|
if cr.CronJobID, err = singleton.CronShared.AddFunc(cr.Scheduler, singleton.CronTrigger(&cr)); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
|
|
if err = singleton.DB.Create(&cr).Error; err != nil {
|
|
return 0, newGormError("%v", err)
|
|
}
|
|
|
|
singleton.CronShared.Update(&cr)
|
|
return cr.ID, nil
|
|
}
|
|
|
|
// Update schedule task
|
|
// @Summary Update schedule task
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Update schedule task
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @param id path uint true "Task ID"
|
|
// @param request body model.CronForm true "CronForm"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[any]
|
|
// @Router /cron/{id} [patch]
|
|
func updateCron(c *gin.Context) (any, error) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var cf model.CronForm
|
|
if err := c.ShouldBindJSON(&cf); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if !isValidCronCover(cf.Cover) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
var cr model.Cron
|
|
if err := singleton.DB.First(&cr, id).Error; err != nil {
|
|
return nil, singleton.Localizer.ErrorT("task id %d does not exist", id)
|
|
}
|
|
|
|
if !cr.HasPermission(c) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
if err := checkCronServerListPermission(c, cf.Cover, cf.Servers, cr.GetUserID()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := rejectImplicitCoverForLimitedPATWithOwner(c, cf.Cover, cf.Servers, cr.GetUserID()); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := assertOwnsNotificationGroup(c, cf.NotificationGroupID); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cr.TaskType = cf.TaskType
|
|
cr.Name = cf.Name
|
|
cr.Scheduler = cf.Scheduler
|
|
cr.Command = cf.Command
|
|
cr.Servers = cf.Servers
|
|
cr.PushSuccessful = cf.PushSuccessful
|
|
cr.NotificationGroupID = cf.NotificationGroupID
|
|
cr.Cover = cf.Cover
|
|
|
|
if cr.TaskType == model.CronTypeCronTask && cr.Cover == model.CronCoverAlertTrigger {
|
|
return nil, singleton.Localizer.ErrorT("scheduled tasks cannot be triggered by alarms")
|
|
}
|
|
|
|
// 对于计划任务类型,需要更新CronJob
|
|
if cf.TaskType == model.CronTypeCronTask {
|
|
if cr.CronJobID, err = singleton.CronShared.AddFunc(cr.Scheduler, singleton.CronTrigger(&cr)); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err = singleton.DB.Save(&cr).Error; err != nil {
|
|
return nil, newGormError("%v", err)
|
|
}
|
|
|
|
singleton.CronShared.Update(&cr)
|
|
return nil, nil
|
|
}
|
|
|
|
// Trigger schedule task
|
|
// @Summary Trigger schedule task
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Trigger schedule task
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @param id path uint true "Task ID"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[any]
|
|
// @Router /cron/{id}/manual [post]
|
|
func manualTriggerCron(c *gin.Context) (any, error) {
|
|
idStr := c.Param("id")
|
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cr, ok := singleton.CronShared.Get(id)
|
|
if !ok {
|
|
return nil, singleton.Localizer.ErrorT("task id %d does not exist", id)
|
|
}
|
|
|
|
if !cr.HasPermission(c) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
// 运行时回放写侧 rejectImplicitCoverForLimitedPAT* 同一条 PAT 收口:
|
|
// 历史脏数据 / 旁路写入的 cron 仍可能携带「CronCoverAll + 不充分 deny-list」
|
|
// 的配置;CronTrigger 没有 PAT 上下文,manualTrigger 这里是唯一阻止
|
|
// 受限 PAT 触发 fan-out 到白名单外 owner servers 的同步入口。
|
|
if err := enforcePATCronDispatchScope(c, cr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
singleton.ManualTrigger(cr)
|
|
return nil, nil
|
|
}
|
|
|
|
// Batch delete schedule tasks
|
|
// @Summary Batch delete schedule tasks
|
|
// @Security BearerAuth
|
|
// @Schemes
|
|
// @Description Batch delete schedule tasks
|
|
// @Tags auth required
|
|
// @Accept json
|
|
// @param request body []uint64 true "id list"
|
|
// @Produce json
|
|
// @Success 200 {object} model.CommonResponse[any]
|
|
// @Router /batch-delete/cron [post]
|
|
func batchDeleteCron(c *gin.Context) (any, error) {
|
|
var cr []uint64
|
|
if err := c.ShouldBindJSON(&cr); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !singleton.CronShared.CheckPermission(c, slices.Values(cr)) {
|
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
|
}
|
|
|
|
// 与 manualTriggerCron 对称:删除会改变 fan-out 范围本身,受限 PAT 不
|
|
// 应通过删除一个白名单内的「掩护」cron 间接放大对白名单外 owner servers
|
|
// 的影响。回放同一条 cover-fanout 收口。
|
|
for _, id := range cr {
|
|
existing, ok := singleton.CronShared.Get(id)
|
|
if !ok || existing == nil {
|
|
continue
|
|
}
|
|
if err := enforcePATCronDispatchScope(c, existing); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
if err := singleton.DB.Unscoped().Delete(&model.Cron{}, "id in (?)", cr).Error; err != nil {
|
|
return nil, newGormError("%v", err)
|
|
}
|
|
|
|
singleton.CronShared.Delete(cr)
|
|
return nil, nil
|
|
}
|