mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-09-19 17:50: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>
351 lines
11 KiB
Go
351 lines
11 KiB
Go
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
|
||
)
|
||
|
||
// 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"`
|
||
EnableShowInService bool `gorm:"default: false" json:"enable_show_in_service,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 {
|
||
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 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 判断该任务类型是否需要进行服务监控 需要则返回true
|
||
func IsServiceSentinelNeeded(t uint64) bool {
|
||
switch t {
|
||
case TaskTypeCommand, TaskTypeTerminalGRPC, TaskTypeUpgrade,
|
||
TaskTypeKeepalive, TaskTypeNAT, TaskTypeFM,
|
||
TaskTypeReportConfig, TaskTypeApplyConfig,
|
||
TaskTypeServerTransferApply:
|
||
return false
|
||
default:
|
||
return true
|
||
}
|
||
}
|