package model import ( "fmt" "log" "github.com/gin-gonic/gin" "github.com/goccy/go-json" "github.com/robfig/cron/v3" "gorm.io/gorm" pb "github.com/nezhahq/nezha/proto" ) const ( _ = iota TaskTypeHTTPGet TaskTypeICMPPing TaskTypeTCPPing TaskTypeCommand TaskTypeTerminal TaskTypeUpgrade TaskTypeKeepalive TaskTypeTerminalGRPC TaskTypeNAT TaskTypeReportHostInfoDeprecated TaskTypeFM TaskTypeReportConfig TaskTypeApplyConfig // TaskTypeServerTransferApply: per-transfer credential rotation. // Pre-transfer agents do not recognise this type — dashboard MUST gate // transfers on agent capability before pushing. TaskTypeServerTransferApply TaskTypeExec TaskTypeFsList TaskTypeFsRead TaskTypeFsWrite TaskTypeFsDelete TaskTypeFsTransfer ) // IsServiceMonitorType reports whether t is a passive service probe. Service // monitors and privileged Agent-control tasks share the protobuf Task.Type // namespace, so every path that persists, schedules, or dispatches a Service // must use this allowlist instead of accepting an arbitrary task integer. func IsServiceMonitorType(t uint64) bool { switch t { case TaskTypeHTTPGet, TaskTypeICMPPing, TaskTypeTCPPing: return true default: return false } } // ValidateServiceMonitorType returns an actionable error at API, model, and // scheduler boundaries. Keeping the check in model avoids a future caller // accidentally turning a monitor-only capability into Agent command/config // execution by copying Service.Type into pb.Task.Type. func ValidateServiceMonitorType(t uint64) error { if !IsServiceMonitorType(t) { return fmt.Errorf("invalid service monitor type %d: allowed types are 1 (HTTP GET), 2 (ICMP ping), and 3 (TCP ping)", t) } return nil } // IsMCPRPCResult 判定一个 TaskResult.Type 是否属于 MCP 走 RequestTask 通道的 // 一次性 RPC 类型。dashboard 的 RequestTask 接收循环用它把这些回包路由到 // Server.inflightRPC 等待方,而不是走 ServiceSentinel。 // // TaskTypeFsTransfer 走 IOStream 而不是 RequestTask 回包,故不在此列;agent // 不会对它发 TaskResult。 func IsMCPRPCResult(t uint64) bool { switch t { case TaskTypeExec, TaskTypeFsList, TaskTypeFsRead, TaskTypeFsWrite, TaskTypeFsDelete: return true } return false } // ExecRequest 是 server.exec 通过 Task.Data 下发到 agent 的载荷(JSON)。 type ExecRequest struct { Cmd string `json:"cmd"` Args []string `json:"args,omitempty"` Cwd string `json:"cwd,omitempty"` Env map[string]string `json:"env,omitempty"` TimeoutSeconds uint32 `json:"timeout_seconds,omitempty"` Stdin string `json:"stdin,omitempty"` MaxOutputBytes uint32 `json:"max_output_bytes,omitempty"` } // ExecResult 是 agent 通过 TaskResult.Data 回传的执行结果(JSON)。 type ExecResult struct { ExitCode int `json:"exit_code"` Stdout string `json:"stdout"` Stderr string `json:"stderr"` DurationMs int64 `json:"duration_ms"` StdoutTruncated bool `json:"stdout_truncated,omitempty"` StderrTruncated bool `json:"stderr_truncated,omitempty"` TimedOut bool `json:"timed_out,omitempty"` Error string `json:"error,omitempty"` } // FsListRequest fs.list 下发载荷。 type FsListRequest struct { Path string `json:"path"` ShowHidden bool `json:"show_hidden,omitempty"` } // FsEntry 单条目录元数据。 type FsEntry struct { Name string `json:"name"` Type string `json:"type"` Size int64 `json:"size"` Mode string `json:"mode"` ModTimeUnix int64 `json:"mtime"` IsSymlink bool `json:"is_symlink,omitempty"` LinkTarget string `json:"link_target,omitempty"` } // FsListResult fs.list 回包。 type FsListResult struct { Entries []FsEntry `json:"entries"` Truncated bool `json:"truncated,omitempty"` Total int `json:"total,omitempty"` Error string `json:"error,omitempty"` } // FsReadRequest fs.read 下发载荷。Offset/Length 单位为字节;encoding 控制返回。 type FsReadRequest struct { Path string `json:"path"` Offset int64 `json:"offset,omitempty"` Length int64 `json:"length,omitempty"` Encoding string `json:"encoding,omitempty"` } // FsReadResult fs.read 回包。Content 按 encoding 编码(utf8 原文 / base64 二进制安全)。 type FsReadResult struct { Content string `json:"content"` Encoding string `json:"encoding"` Size int64 `json:"size"` SHA256 string `json:"sha256,omitempty"` Truncated bool `json:"truncated,omitempty"` Error string `json:"error,omitempty"` } // FsWriteRequest fs.write 下发载荷。Mode 用 unix 数字字符串如 "0644"。 type FsWriteRequest struct { Path string `json:"path"` Content string `json:"content"` Encoding string `json:"encoding,omitempty"` Mode string `json:"mode,omitempty"` IfMatchSHA256 string `json:"if_match_sha256,omitempty"` CreateDirs bool `json:"create_dirs,omitempty"` } // FsWriteResult fs.write 回包。 type FsWriteResult struct { Size int64 `json:"size"` SHA256 string `json:"sha256"` Error string `json:"error,omitempty"` } // FsDeleteRequest fs.delete 下发载荷。 type FsDeleteRequest struct { Path string `json:"path"` Recursive bool `json:"recursive,omitempty"` } // FsDeleteResult fs.delete 回包。 type FsDeleteResult struct { DeletedCount int `json:"deleted_count"` Error string `json:"error,omitempty"` } const ( // MCPFsTransferOpUpload / Download 区分 IOStream 内的数据流向。 MCPFsTransferOpUpload = "upload" MCPFsTransferOpDownload = "download" // MCPFsTransferMaxSize 单次传输硬上限,dashboard 和 agent 双方都拒绝 // 超出大小的请求。设为 100MiB 与产品语义"~100MB 大文件"对齐。 MCPFsTransferMaxSize = 100 * 1024 * 1024 ) // FsTransferRequest 通过 Task.Data 下发到 agent;agent 据此打开本地 // IOStream,按 op 完成上/下行。streamId 用于 agent IOStream 引导帧。 type FsTransferRequest struct { StreamID string `json:"stream_id"` Op string `json:"op"` Path string `json:"path"` Size int64 `json:"size,omitempty"` Mode string `json:"mode,omitempty"` CreateDirs bool `json:"create_dirs,omitempty"` IfMatchSHA256 string `json:"if_match_sha256,omitempty"` ExpectedSHA256 string `json:"expected_sha256,omitempty"` } // 双向 IOStream 控制帧 magic(每帧第一帧的前 4 字节)。数据帧不带 magic。 // // 这套 magic 与 FM 协议(NZTD/NZFN/NERR/NZUP)共存而不冲突:NZTD 在 FM 表示 // "file header",在 transfer 表示"download header",但两条协议通过不同的 // task type(TaskTypeFM vs TaskTypeFsTransfer)分流,不会复用同一个 agent // goroutine,所以 magic 撞名只是字面巧合,不会破坏解析。 var ( MCPFsXferMagicUploadHdr = []byte{0x4E, 0x5A, 0x54, 0x55} // NZTU MCPFsXferMagicDownloadHdr = []byte{0x4E, 0x5A, 0x54, 0x44} // NZTD MCPFsXferMagicOK = []byte{0x4E, 0x5A, 0x54, 0x4F} // NZTO MCPFsXferMagicErr = []byte{0x4E, 0x5A, 0x54, 0x45} // NZTE MCPFsXferMagicChunk = []byte{0x4E, 0x5A, 0x54, 0x43} // NZTC: download data chunk ) type TerminalTask struct { StreamID string } type TaskNAT struct { StreamID string Host string } type TaskFM struct { StreamID string } const ( ServiceCoverAll = iota ServiceCoverIgnoreAll ) type Service struct { Common Name string `json:"name"` Type uint8 `json:"type"` Target string `json:"target"` SkipServersRaw string `json:"-"` Duration uint64 `json:"duration"` DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前 Notify bool `json:"notify,omitempty"` NotificationGroupID uint64 `json:"notification_group_id"` // 当前服务监控所属的通知组 ID Cover uint8 `json:"cover"` EnableTriggerTask bool `gorm:"default: false" json:"enable_trigger_task,omitempty"` HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏 FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"` RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"` FailTriggerTasks []uint64 `gorm:"-" json:"fail_trigger_tasks"` // 失败时执行的触发任务id RecoverTriggerTasks []uint64 `gorm:"-" json:"recover_trigger_tasks"` // 恢复时执行的触发任务id MinLatency float32 `json:"min_latency"` MaxLatency float32 `json:"max_latency"` LatencyNotify bool `json:"latency_notify,omitempty"` SkipServers map[uint64]bool `gorm:"-" json:"skip_servers"` CronJobID cron.EntryID `gorm:"-" json:"-"` } func (m *Service) PB() *pb.Task { if m == nil || !IsServiceMonitorType(uint64(m.Type)) { return nil } return &pb.Task{ Id: m.ID, Type: uint64(m.Type), Data: m.Target, } } // HasPermission 扩展默认的 owner/admin 检查,让 PAT 的 server_ids 白名单 // 同样能收窄 service monitor 的列出/删除/更新路径,语义与 Cron.HasPermission // 对齐: // - ServiceCoverAll:SkipServers 是 deny-set。DispatchTask 会探测 owner 在 // deny-set 之外的所有 server,所以受限 PAT 必须保证 deny-set 已经覆盖 // 白名单外的全部 owner servers。判定与 controller 的 // enforcePATServiceDispatchScope / rejectImplicitServiceCoverForLimitedPAT // 共用 denyListSafeForLimitedPAT。 // - ServiceCoverIgnoreAll:SkipServers 是 allow-set,要求每个被覆盖的 // server 都在 PAT 白名单内。 // - 其它情况保留旧的“PAT 按 owner 关系判定”行为。 func (m *Service) HasPermission(ctx *gin.Context) bool { if !m.Common.HasPermission(ctx) { return false } v, ok := ctx.Get(CtxKeyAPIToken) if !ok { return true } tok, _ := v.(APITokenAccessor) if tok == nil { return true } switch m.Cover { case ServiceCoverAll: return DenyListSafeForLimitedPAT(tok, m.GetUserID(), skipServersTrueIDs(m.SkipServers)) case ServiceCoverIgnoreAll: for _, id := range skipServersTrueIDs(m.SkipServers) { if !tok.CanAccessServer(id) { return false } } return true default: return true } } func skipServersTrueIDs(skip map[uint64]bool) []uint64 { if len(skip) == 0 { return nil } out := make([]uint64, 0, len(skip)) for id, mark := range skip { if mark { out = append(out, id) } } return out } // CronSpec 返回服务监控请求间隔对应的 cron 表达式 func (m *Service) CronSpec() string { if m.Duration == 0 { // 默认间隔 30 秒 m.Duration = 30 } return fmt.Sprintf("@every %ds", m.Duration) } func (m *Service) BeforeSave(tx *gorm.DB) error { if err := ValidateServiceMonitorType(uint64(m.Type)); err != nil { return err } if data, err := json.Marshal(m.SkipServers); err != nil { return err } else { m.SkipServersRaw = string(data) } if data, err := json.Marshal(m.FailTriggerTasks); err != nil { return err } else { m.FailTriggerTasksRaw = string(data) } if data, err := json.Marshal(m.RecoverTriggerTasks); err != nil { return err } else { m.RecoverTriggerTasksRaw = string(data) } return nil } func (m *Service) AfterFind(tx *gorm.DB) error { m.SkipServers = make(map[uint64]bool) if err := json.Unmarshal([]byte(m.SkipServersRaw), &m.SkipServers); err != nil { log.Println("NEZHA>> Service.AfterFind:", err) return nil } // 加载触发任务列表 if err := json.Unmarshal([]byte(m.FailTriggerTasksRaw), &m.FailTriggerTasks); err != nil { return err } if err := json.Unmarshal([]byte(m.RecoverTriggerTasksRaw), &m.RecoverTriggerTasks); err != nil { return err } return nil } // IsServiceSentinelNeeded accepts results only for the three probe types. An // unknown or privileged task type must never enter ServiceSentinel merely // because it was not listed in a denylist. func IsServiceSentinelNeeded(t uint64) bool { return IsServiceMonitorType(t) }