feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation

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>
This commit is contained in:
naiba
2026-05-30 15:56:44 +00:00
co-authored by cloudcode
parent 029695344c
commit e8dabf5bc6
153 changed files with 16974 additions and 244 deletions
+57
View File
@@ -3,6 +3,7 @@ package model
import (
"slices"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"gorm.io/gorm"
)
@@ -63,6 +64,62 @@ func (r *AlertRule) Enabled() bool {
return r.Enable != nil && *r.Enable
}
// HasPermission extends the default owner/admin check with PAT
// server_ids whitelist enforcement. AlertRule.Snapshot fans out across
// every owner-visible server filtered only by Rule.Ignore semantics
// (RuleCoverAll: deny-list; RuleCoverIgnoreAll: allow-list). A
// server-limited PAT must therefore satisfy the same cover-fanout rule
// the cron / service paths use — otherwise it can create or update a
// rule that monitors servers outside its whitelist (admin owner: any
// server in the system).
//
// Unknown Rule.Cover is fail-closed: Snapshot's switch defaults to
// "monitor everything", so persisting it would defeat the PAT cover
// guard. createAlertRule / updateAlertRule should also reject unknown
// covers at write time; this method is the runtime safety net.
func (r *AlertRule) HasPermission(ctx *gin.Context) bool {
if !r.Common.HasPermission(ctx) {
return false
}
v, ok := ctx.Get(CtxKeyAPIToken)
if !ok {
return true
}
tok, _ := v.(APITokenAccessor)
if tok == nil {
return true
}
if wl, ok := tok.(APITokenWhitelistView); ok && len(wl.ServerIDs()) == 0 {
return true
}
for _, rule := range r.Rules {
if rule == nil {
continue
}
switch rule.Cover {
case RuleCoverAll:
denyIDs := make([]uint64, 0, len(rule.Ignore))
for id, ignored := range rule.Ignore {
if ignored {
denyIDs = append(denyIDs, id)
}
}
if !DenyListSafeForLimitedPAT(tok, r.GetUserID(), denyIDs) {
return false
}
case RuleCoverIgnoreAll:
for id, monitored := range rule.Ignore {
if monitored && !tok.CanAccessServer(id) {
return false
}
}
default:
return false
}
}
return true
}
// Snapshot 对传入的Server进行该报警规则下所有type的检查 返回每项检查结果
func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []bool {
point := make([]bool, len(r.Rules))
+227
View File
@@ -0,0 +1,227 @@
package model
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// H4 regression: AlertRule had no AlertRule.HasPermission override, so
// limited PATs could create / list / update rules that fan out to every
// owner server. A RuleCoverAll + empty Ignore rule monitors every server
// the owner can reach (admin owner: every server in the system), which is
// exactly the cover-fanout the PAT whitelist is supposed to contain.
func TestAlertRuleHasPermission_DeniesRuleCoverAllEmptyIgnoreForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
OwnerServerIDsLookup = func(uid uint64) []uint64 {
if uid == 100 {
return []uint64{1, 2}
}
return nil
}
OwnerIsAdminLookup = func(uid uint64) bool { return false }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2, 3} }
rule := &AlertRule{
Common: Common{ID: 9, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverAll,
Ignore: nil,
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if rule.HasPermission(ctx) {
t.Fatal("server-limited PAT must not be allowed to operate on RuleCoverAll with empty Ignore — runtime fans out to owner server 2")
}
}
func TestAlertRuleHasPermission_AllowsRuleCoverIgnoreAllEmptyIgnore(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 10, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverIgnoreAll,
Ignore: nil, // allow-list of zero ⇒ no-op
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !rule.HasPermission(ctx) {
t.Fatal("RuleCoverIgnoreAll + empty Ignore is a no-op rule; PAT must remain allowed")
}
}
func TestAlertRuleHasPermission_DeniesUnknownCover(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1} }
rule := &AlertRule{
Common: Common{ID: 11, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: 99, // unknown ⇒ runtime Snapshot falls through to "monitor everything"
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if rule.HasPermission(ctx) {
t.Fatal("unknown Rule.Cover must fail-closed for limited PAT — Snapshot does not gate on it")
}
}
// Regression: AlertRule.HasPermission built the deny-list from every key in
// Rule.Ignore regardless of its bool value, but Rule.Snapshot only skips a
// server when Ignore[id] == true. A limited PAT whitelisted to {1} could
// submit RuleCoverAll with Ignore{2: false}; the permission check treated 2 as
// denied (safe) while the runtime still monitored server 2.
func TestAlertRuleHasPermission_DeniesRuleCoverAllIgnoreFalseForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
OwnerServerIDsLookup = func(uid uint64) []uint64 {
if uid == 100 {
return []uint64{1, 2}
}
return nil
}
OwnerIsAdminLookup = func(uid uint64) bool { return false }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2, 3} }
rule := &AlertRule{
Common: Common{ID: 13, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverAll,
Ignore: map[uint64]bool{2: false}, // key present but NOT actually ignored at runtime
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if rule.HasPermission(ctx) {
t.Fatal("Ignore{2:false} does NOT exclude server 2 at runtime; limited PAT must be denied")
}
}
// A genuine deny entry (value true) for every out-of-whitelist server keeps the
// rule contained and must remain allowed.
func TestAlertRuleHasPermission_AllowsRuleCoverAllIgnoreTrueCoversWhitelistGap(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
OwnerIsAdminLookup = func(uid uint64) bool { return false }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 14, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverAll,
Ignore: map[uint64]bool{2: true}, // server 2 genuinely excluded
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !rule.HasPermission(ctx) {
t.Fatal("Ignore{2:true} excludes the only out-of-whitelist server; PAT must be allowed")
}
}
// Regression: the RuleCoverIgnoreAll branch checked CanAccessServer for every
// key in Rule.Ignore, but Rule.Snapshot only monitors a server when
// Ignore[id] == true. A limited PAT whitelisted to {1} submitting
// RuleCoverIgnoreAll with Ignore{2: false} (server 2 is NOT monitored at
// runtime) was wrongly denied because the foreign key 2 failed the whitelist.
func TestAlertRuleHasPermission_AllowsRuleCoverIgnoreAllIgnoreFalseForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 15, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverIgnoreAll,
Ignore: map[uint64]bool{2: false}, // key present but server 2 is NOT monitored
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if !rule.HasPermission(ctx) {
t.Fatal("Ignore{2:false} does NOT monitor server 2 at runtime; limited PAT must remain allowed")
}
}
// The genuine allow entry (value true) for an out-of-whitelist server is the
// case that must still be denied.
func TestAlertRuleHasPermission_DeniesRuleCoverIgnoreAllIgnoreTrueForLimitedPAT(t *testing.T) {
saved := OwnerServerIDsLookup
t.Cleanup(func() { OwnerServerIDsLookup = saved })
OwnerServerIDsLookup = func(uid uint64) []uint64 { return []uint64{1, 2} }
rule := &AlertRule{
Common: Common{ID: 16, UserID: 100},
Rules: []*Rule{{
Type: "cpu",
Cover: RuleCoverIgnoreAll,
Ignore: map[uint64]bool{2: true}, // server 2 IS monitored at runtime
}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // doesn't cover 2
if rule.HasPermission(ctx) {
t.Fatal("Ignore{2:true} monitors server 2; server-limited PAT must be denied")
}
}
func TestAlertRuleHasPermission_NoPATPassesViaCommonHasPermission(t *testing.T) {
rule := &AlertRule{
Common: Common{ID: 12, UserID: 100},
Rules: []*Rule{{Type: "cpu", Cover: RuleCoverAll}},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
if !rule.HasPermission(ctx) {
t.Fatal("owner without PAT must keep the existing owner/admin pass")
}
}
+373
View File
@@ -0,0 +1,373 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
"time"
"gorm.io/gorm"
)
// Scope 命名规范(唯一一套):nezha:{resource}:{verb}
//
// - resource: server / service / alertrule / cron / ddns / nat /
// notification / notification-group / transfer / admin
// - verb: read / write / delete / exec
//
// `*` 通配在 resource 或 verb 位均可:
// - nezha:server:* 给定资源的所有动作
// - nezha:* admin-only 全权
//
// 同一 scope 同时管 MCP tool 和 REST endpoint:例如 nezha:server:read 既允许
// `server.list` MCP tool,也允许 `GET /api/v1/server`。
//
// 历史上还有 mcp:* 一套,会被 HasScope 通过别名映射到 nezha:server:* 子集。
// 由于 HasScope 同时服务 MCP tool 调度与 REST scope middleware,旧 mcp:fs:write
// 等会静默扩到所有 nezha:server:write REST 路由——这是命名分裂带来的提权漏洞。
// 现在 mcp:* 不再在运行时被识别;createAPIToken 入口对老调用方做一次性归一化:
// 只读/exec 类(mcp:fs:read、mcp:server:read、mcp:server:exec)映射到对应
// nezha:* read/exec scopemcp:fs:write、mcp:fs:delete、mcp:* 一律拒签。
// 数据库已有的危险旧 scope 由 MigrateLegacyMCPScopes 在启动迁移阶段清理。
const (
ScopeNezhaAll = "nezha:*"
ScopeServerRead = "nezha:server:read"
ScopeServerWrite = "nezha:server:write"
ScopeServerDelete = "nezha:server:delete"
ScopeServerExec = "nezha:server:exec"
ScopeServiceRead = "nezha:service:read"
ScopeServiceWrite = "nezha:service:write"
ScopeServiceDelete = "nezha:service:delete"
ScopeAlertRuleRead = "nezha:alertrule:read"
ScopeAlertRuleWrite = "nezha:alertrule:write"
ScopeAlertRuleDelete = "nezha:alertrule:delete"
ScopeCronRead = "nezha:cron:read"
ScopeCronWrite = "nezha:cron:write"
ScopeCronDelete = "nezha:cron:delete"
ScopeCronExec = "nezha:cron:exec"
ScopeDDNSRead = "nezha:ddns:read"
ScopeDDNSWrite = "nezha:ddns:write"
ScopeDDNSDelete = "nezha:ddns:delete"
ScopeNATRead = "nezha:nat:read"
ScopeNATWrite = "nezha:nat:write"
ScopeNATDelete = "nezha:nat:delete"
ScopeNotificationRead = "nezha:notification:read"
ScopeNotificationWrite = "nezha:notification:write"
ScopeNotificationDelete = "nezha:notification:delete"
ScopeNotificationGroupRead = "nezha:notification-group:read"
ScopeNotificationGroupWrite = "nezha:notification-group:write"
ScopeNotificationGroupDelete = "nezha:notification-group:delete"
ScopeTransferRead = "nezha:transfer:read"
ScopeTransferWrite = "nezha:transfer:write"
ScopeTransferDelete = "nezha:transfer:delete"
ScopeAdminAll = "nezha:admin:*"
)
var AllScopes = []string{
ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec,
ScopeServiceRead, ScopeServiceWrite, ScopeServiceDelete,
ScopeAlertRuleRead, ScopeAlertRuleWrite, ScopeAlertRuleDelete,
ScopeCronRead, ScopeCronWrite, ScopeCronDelete, ScopeCronExec,
ScopeDDNSRead, ScopeDDNSWrite, ScopeDDNSDelete,
ScopeNATRead, ScopeNATWrite, ScopeNATDelete,
ScopeNotificationRead, ScopeNotificationWrite, ScopeNotificationDelete,
ScopeNotificationGroupRead, ScopeNotificationGroupWrite, ScopeNotificationGroupDelete,
ScopeTransferRead, ScopeTransferWrite, ScopeTransferDelete,
"nezha:server:*",
"nezha:service:*",
"nezha:alertrule:*",
"nezha:cron:*",
"nezha:ddns:*",
"nezha:nat:*",
"nezha:notification:*",
"nezha:notification-group:*",
"nezha:transfer:*",
}
var AdminOnlyScopes = []string{ScopeNezhaAll, ScopeAdminAll}
// legacyMCPReadOnlyRewrite 列出仍允许 createAPIToken 入口重写为 nezha:* 的旧 scope。
// 只有只读/exec 类被接受;write/delete/wildcard 一律拒签——保留映射等于扩权。
var legacyMCPReadOnlyRewrite = map[string]string{
"mcp:server:read": ScopeServerRead,
"mcp:server:exec": ScopeServerExec,
"mcp:fs:read": ScopeServerRead,
}
// NormalizeIncomingScope 把入参里的旧 mcp:* scope 重写到 nezha:* 命名。
// 第二个返回值表示该 scope 是否被允许(false = 危险旧 scope,调用方应拒签)。
func NormalizeIncomingScope(s string) (string, bool) {
if mapped, ok := legacyMCPReadOnlyRewrite[s]; ok {
return mapped, true
}
if strings.HasPrefix(s, "mcp:") {
return s, false
}
return s, true
}
// APITokenPrefix 是明文 token 的人类可识别前缀。`nzp_` = nezha personal access token。
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:"-"`
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"`
}
func (APIToken) TableName() string {
return "api_tokens"
}
// HashAPIToken 计算明文 token 的存储哈希。
func HashAPIToken(plaintext string) string {
sum := sha256.Sum256([]byte(plaintext))
return hex.EncodeToString(sum[:])
}
// Scopes 解码逗号分隔的 scope 列表。
func (t *APIToken) Scopes() []string {
if t.ScopesCSV == "" {
return nil
}
parts := strings.Split(t.ScopesCSV, ",")
out := parts[:0]
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// SetScopes 编码 scope 列表为 CSV。
func (t *APIToken) SetScopes(scopes []string) {
t.ScopesCSV = strings.Join(scopes, ",")
}
// ServerIDs 解码服务器 ID 白名单。空切片 = 不限制(继承用户原有权限)。
func (t *APIToken) ServerIDs() []uint64 {
if t.ServersCSV == "" {
return nil
}
parts := strings.Split(t.ServersCSV, ",")
out := make([]uint64, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
var id uint64
for _, c := range p {
if c < '0' || c > '9' {
id = 0
break
}
id = id*10 + uint64(c-'0')
}
if id != 0 {
out = append(out, id)
}
}
return out
}
// SetServerIDs 编码服务器 ID 白名单。
func (t *APIToken) SetServerIDs(ids []uint64) {
parts := make([]string, 0, len(ids))
for _, id := range ids {
parts = append(parts, formatUint(id))
}
t.ServersCSV = strings.Join(parts, ",")
}
// HasScope 判定 token 是否携带某个 scope。
//
// 匹配规则:
// - nezha:* 覆盖整个 nezha 命名空间
// - 资源级通配:nezha:server:* 匹配所有 nezha:server:read/write/delete/exec
// - 精确匹配
//
// 不再做 mcp:* 别名展开;任何遗留的 mcp:* scope 都视为无效(已被
// MigrateLegacyMCPScopes 在启动迁移阶段清掉;运行时再遇到当作无权处理)。
func (t *APIToken) HasScope(scope string) bool {
for _, s := range t.Scopes() {
if scopeMatches(s, scope) {
return true
}
}
return false
}
// scopeMatches 判定 owned scope 是否覆盖 wanted scope。
func scopeMatches(owned, wanted string) bool {
if owned == wanted {
return true
}
if owned == ScopeNezhaAll {
return strings.HasPrefix(wanted, "nezha:")
}
if strings.HasSuffix(owned, ":*") {
prefix := strings.TrimSuffix(owned, ":*")
return strings.HasPrefix(wanted, prefix+":") || wanted == prefix
}
return false
}
// CanAccessServer 判定 token 是否被允许操作某 server(白名单层;
// 仍需上层调用 Server.HasPermission 做用户级权限校验)。
func (t *APIToken) CanAccessServer(serverID uint64) bool {
ids := t.ServerIDs()
if len(ids) == 0 {
return true
}
return slices.Contains(ids, serverID)
}
// IsExpired 判定 token 是否已过期。ExpiresAt 为 nil 表示永不过期。
func (t *APIToken) IsExpired(now time.Time) bool {
return t.ExpiresAt != nil && now.After(*t.ExpiresAt)
}
// BeforeCreate 在写入前强校验 TokenHash 必填,避免空哈希撞键。
func (t *APIToken) BeforeCreate(tx *gorm.DB) error {
if t.TokenHash == "" {
return gorm.ErrInvalidData
}
return nil
}
// MigrateLegacyMCPScopes 把数据库里残留的 mcp:* scope 一次性归一化:
// - 只读/exec 类映射到对应 nezha:* read/exec scope
// - mcp:fs:write / mcp:fs:delete / mcp:* 会被剥掉(不再赋予 REST write/delete),
// 若 token 因此 scope 列表清空则整体删除——避免出现一张 0 scope 但仍能命中
// auth middleware 的 PAT。
//
// 返回 (rewrittenTokens, deletedTokens, err)。生产路径在启动时调用一次;
// 测试也会用它构造 fixture。
func MigrateLegacyMCPScopes(db *gorm.DB) (int, int, error) {
if db == nil {
return 0, 0, nil
}
var rows []APIToken
if err := db.Where("scopes_csv LIKE ?", "%mcp:%").Find(&rows).Error; err != nil {
return 0, 0, err
}
rewritten, deleted := 0, 0
for i := range rows {
tok := &rows[i]
old := tok.Scopes()
next := make([]string, 0, len(old))
seen := make(map[string]struct{}, len(old))
for _, s := range old {
mapped, ok := NormalizeIncomingScope(s)
if !ok {
continue
}
if _, dup := seen[mapped]; dup {
continue
}
seen[mapped] = struct{}{}
next = append(next, mapped)
}
if len(next) == 0 {
if err := db.Delete(&APIToken{}, tok.ID).Error; err != nil {
return rewritten, deleted, err
}
deleted++
continue
}
joined := strings.Join(next, ",")
if joined == tok.ScopesCSV {
continue
}
if err := db.Model(&APIToken{}).Where("id = ?", tok.ID).
Update("scopes_csv", joined).Error; err != nil {
return rewritten, deleted, err
}
rewritten++
}
return rewritten, deleted, nil
}
// formatUint —— 小工具,避免引入 strconv。
func formatUint(v uint64) string {
if v == 0 {
return "0"
}
var buf [20]byte
i := len(buf)
for v > 0 {
i--
buf[i] = byte('0' + v%10)
v /= 10
}
return string(buf[i:])
}
// APITokenCreateRequest 是创建 PAT 接口的入参。
type APITokenCreateRequest struct {
Name string `json:"name" binding:"required,max=128"`
Scopes []string `json:"scopes" binding:"required,min=1,dive,max=64"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresInDays int `json:"expires_in_days,omitempty"` // 0 = 永不过期
}
// APITokenCreateResponse 创建 PAT 接口的出参;明文 token 仅在此刻返回一次。
type APITokenCreateResponse struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Token string `json:"token"`
Scopes []string `json:"scopes"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
// APITokenView 是 PAT 列表展示用的脱敏视图。
type APITokenView struct {
ID uint64 `json:"id"`
Name string `json:"name"`
Scopes []string `json:"scopes"`
ServerIDs []uint64 `json:"server_ids,omitempty"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
LastUsedIP string `json:"last_used_ip,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ToView 把数据库实体转为列表脱敏视图。
func (t *APIToken) ToView() APITokenView {
return APITokenView{
ID: t.ID,
Name: t.Name,
Scopes: t.Scopes(),
ServerIDs: t.ServerIDs(),
ExpiresAt: t.ExpiresAt,
LastUsedAt: t.LastUsedAt,
LastUsedIP: t.LastUsedIP,
CreatedAt: t.CreatedAt,
}
}
+112
View File
@@ -0,0 +1,112 @@
package model
import (
"strings"
"testing"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func newMigrationTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&APIToken{}); err != nil {
t.Fatalf("migrate: %v", err)
}
return db
}
func TestNormalizeIncomingScope_RewritesReadOnlyMCPVariants(t *testing.T) {
cases := map[string]string{
"mcp:fs:read": ScopeServerRead,
"mcp:server:read": ScopeServerRead,
"mcp:server:exec": ScopeServerExec,
}
for in, want := range cases {
got, ok := NormalizeIncomingScope(in)
if !ok {
t.Fatalf("NormalizeIncomingScope(%q) ok=false; legacy read/exec must remain creatable", in)
}
if got != want {
t.Fatalf("NormalizeIncomingScope(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeIncomingScope_RejectsDangerousLegacyVariants(t *testing.T) {
for _, in := range []string{"mcp:fs:write", "mcp:fs:delete", "mcp:*", "mcp:unknown"} {
if got, ok := NormalizeIncomingScope(in); ok {
t.Errorf("NormalizeIncomingScope(%q) = (%q, true); legacy write/delete/wildcard must be rejected", in, got)
}
}
}
func TestNormalizeIncomingScope_PassesThroughNezhaScopes(t *testing.T) {
got, ok := NormalizeIncomingScope(ScopeServerWrite)
if !ok || got != ScopeServerWrite {
t.Fatalf("nezha:* must pass through unchanged: got (%q, %v)", got, ok)
}
}
func TestMigrateLegacyMCPScopes_RewritesReadOnlyAndDropsDangerous(t *testing.T) {
db := newMigrationTestDB(t)
tokens := []APIToken{
{UserID: 1, Name: "read-only", TokenHash: HashAPIToken("nzp_a"), ScopesCSV: "mcp:fs:read"},
{UserID: 2, Name: "mixed", TokenHash: HashAPIToken("nzp_b"), ScopesCSV: "mcp:server:read,mcp:fs:write"},
{UserID: 3, Name: "purely-dangerous", TokenHash: HashAPIToken("nzp_c"), ScopesCSV: "mcp:fs:write,mcp:*"},
{UserID: 4, Name: "modern", TokenHash: HashAPIToken("nzp_d"), ScopesCSV: ScopeServerRead},
}
for i := range tokens {
if err := db.Create(&tokens[i]).Error; err != nil {
t.Fatalf("seed token %d: %v", i, err)
}
}
rewritten, deleted, err := MigrateLegacyMCPScopes(db)
if err != nil {
t.Fatalf("MigrateLegacyMCPScopes: %v", err)
}
if rewritten < 2 {
t.Fatalf("expected >=2 rewrites (read-only + mixed); got %d", rewritten)
}
if deleted != 1 {
t.Fatalf("expected 1 deleted token (purely-dangerous); got %d", deleted)
}
var got []APIToken
if err := db.Order("id ASC").Find(&got).Error; err != nil {
t.Fatalf("reload: %v", err)
}
if len(got) != 3 {
t.Fatalf("expected 3 surviving tokens; got %d", len(got))
}
for _, tok := range got {
if strings.Contains(tok.ScopesCSV, "mcp:") {
t.Fatalf("token %d still carries legacy scope after migration: %q", tok.ID, tok.ScopesCSV)
}
}
for _, tok := range got {
switch tok.UserID {
case 1:
if tok.ScopesCSV != ScopeServerRead {
t.Fatalf("uid=1 expected %q, got %q", ScopeServerRead, tok.ScopesCSV)
}
case 2:
if tok.ScopesCSV != ScopeServerRead {
t.Fatalf("uid=2 should keep only the safe read scope after drop; got %q", tok.ScopesCSV)
}
case 4:
if tok.ScopesCSV != ScopeServerRead {
t.Fatalf("uid=4 already-modern token must be untouched; got %q", tok.ScopesCSV)
}
default:
t.Fatalf("unexpected surviving token uid=%d", tok.UserID)
}
}
}
+148
View File
@@ -0,0 +1,148 @@
package model
import (
"strings"
"testing"
"time"
)
func TestHashAPIToken_DeterministicAndAvalanche(t *testing.T) {
a := HashAPIToken("nzp_alpha")
b := HashAPIToken("nzp_alpha")
if a != b {
t.Fatalf("hash must be deterministic for identical inputs")
}
c := HashAPIToken("nzp_alphb")
if a == c {
t.Fatalf("single-byte change in input must change hash")
}
if len(a) != 64 {
t.Fatalf("hash must be 64 hex chars (sha256), got %d", len(a))
}
}
func TestAPIToken_HasScope_AllAndExact(t *testing.T) {
tok := &APIToken{}
tok.SetScopes([]string{ScopeServerRead, ScopeServerExec})
if !tok.HasScope(ScopeServerRead) {
t.Fatalf("explicit scope must pass")
}
if !tok.HasScope(ScopeServerExec) {
t.Fatalf("explicit scope must pass")
}
if tok.HasScope(ScopeServerWrite) {
t.Fatalf("missing scope must fail")
}
tok.SetScopes([]string{ScopeNezhaAll})
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
if !tok.HasScope(s) {
t.Fatalf("nezha:* must cover %s", s)
}
}
}
func TestAPIToken_HasScope_TrimsWhitespace(t *testing.T) {
tok := &APIToken{ScopesCSV: " nezha:server:read , nezha:server:exec "}
if !tok.HasScope(ScopeServerRead) {
t.Fatalf("scope with surrounding whitespace must be normalized")
}
}
func TestAPIToken_CanAccessServer_EmptyMeansAll(t *testing.T) {
tok := &APIToken{}
if !tok.CanAccessServer(1) {
t.Fatalf("empty server list must allow any server")
}
if !tok.CanAccessServer(99999) {
t.Fatalf("empty server list must allow any server")
}
}
func TestAPIToken_CanAccessServer_Whitelist(t *testing.T) {
tok := &APIToken{}
tok.SetServerIDs([]uint64{2, 5, 7})
if !tok.CanAccessServer(5) {
t.Fatalf("listed server must be allowed")
}
if tok.CanAccessServer(6) {
t.Fatalf("unlisted server must be denied")
}
}
func TestAPIToken_SetServerIDs_RoundTrip(t *testing.T) {
tok := &APIToken{}
tok.SetServerIDs([]uint64{10, 11, 12})
got := tok.ServerIDs()
if len(got) != 3 || got[0] != 10 || got[2] != 12 {
t.Fatalf("round-trip failed: %v", got)
}
}
func TestAPIToken_ServerIDs_SkipsGarbage(t *testing.T) {
tok := &APIToken{ServersCSV: "1,2,abc,3"}
got := tok.ServerIDs()
if len(got) != 3 || got[0] != 1 || got[1] != 2 || got[2] != 3 {
t.Fatalf("garbage entries must be skipped; got %v", got)
}
}
func TestAPIToken_IsExpired(t *testing.T) {
tok := &APIToken{}
if tok.IsExpired(time.Now()) {
t.Fatalf("nil expiry must mean never expired")
}
past := time.Now().Add(-time.Hour)
tok.ExpiresAt = &past
if !tok.IsExpired(time.Now()) {
t.Fatalf("past expiry must mark expired")
}
future := time.Now().Add(time.Hour)
tok.ExpiresAt = &future
if tok.IsExpired(time.Now()) {
t.Fatalf("future expiry must not mark expired")
}
}
func TestAPIToken_HashAPIToken_NoSecretLeak(t *testing.T) {
plaintext := "nzp_supersecret"
hash := HashAPIToken(plaintext)
if strings.Contains(hash, plaintext) {
t.Fatalf("hash must not contain plaintext")
}
if strings.Contains(hash, "super") {
t.Fatalf("hash must not contain secret substring")
}
}
func TestAPIToken_BeforeCreate_RejectsEmptyHash(t *testing.T) {
tok := &APIToken{Name: "x"}
err := tok.BeforeCreate(nil)
if err == nil {
t.Fatalf("BeforeCreate must reject empty TokenHash")
}
}
func TestAPIToken_BeforeCreate_AcceptsNonEmptyHash(t *testing.T) {
tok := &APIToken{Name: "x", TokenHash: HashAPIToken("nzp_xyz")}
if err := tok.BeforeCreate(nil); err != nil {
t.Fatalf("BeforeCreate must accept non-empty hash, got %v", err)
}
}
func TestAPIToken_ToView_OmitsTokenHash(t *testing.T) {
tok := &APIToken{
ID: 1,
UserID: 2,
Name: "x",
TokenHash: "DEADBEEF",
}
tok.SetScopes([]string{ScopeServerRead})
v := tok.ToView()
if v.ID != 1 || v.Name != "x" {
t.Fatalf("view missing core fields")
}
if len(v.Scopes) != 1 || v.Scopes[0] != ScopeServerRead {
t.Fatalf("view missing scopes")
}
}
+82
View File
@@ -0,0 +1,82 @@
package model
import (
"slices"
"testing"
)
// 这些测试约束「scope 命名统一」契约:
// - 只有 nezha:* 一套是 first-class scope
// - mcp:* 不再作为 HasScope 的别名(避免 mcp:fs:write 静默扩到 REST 的 nezha:server:write);
// - AllScopes / AdminOnlyScopes 不再包含 mcp:*,新建 token 不能再签发它们。
//
// 旧 mcp:* 兼容由 createAPIToken 入口做一次性归一化(mcp:fs:read 等只读/exec 映射到
// 对应的 nezha:* read/exec),但 write/delete 类不再映射;详见 controller.createAPIToken。
func TestAllScopes_DoesNotExposeLegacyMCPScopes(t *testing.T) {
legacy := []string{
"mcp:*",
"mcp:server:read",
"mcp:server:exec",
"mcp:fs:read",
"mcp:fs:write",
"mcp:fs:delete",
}
for _, s := range legacy {
if slices.Contains(AllScopes, s) {
t.Errorf("AllScopes must not advertise legacy scope %q; only nezha:* is first-class", s)
}
if slices.Contains(AdminOnlyScopes, s) {
t.Errorf("AdminOnlyScopes must not advertise legacy scope %q", s)
}
}
}
func TestHasScope_LegacyMCPNoLongerAliasesNezhaWrite(t *testing.T) {
// 旧 token 数据库里残留 mcp:fs:write,绝不允许覆盖 REST 的 nezha:server:write。
tok := &APIToken{ScopesCSV: "mcp:fs:write"}
if tok.HasScope(ScopeServerWrite) {
t.Fatalf("legacy mcp:fs:write must NOT grant nezha:server:write via HasScope; " +
"REST routes (server/config, server/:id, batch-delete/server) would become reachable")
}
if tok.HasScope(ScopeServerDelete) {
t.Fatalf("legacy mcp:fs:write must NOT grant nezha:server:delete")
}
}
func TestHasScope_LegacyMCPDeleteNoLongerAliasesNezhaDelete(t *testing.T) {
tok := &APIToken{ScopesCSV: "mcp:fs:delete"}
if tok.HasScope(ScopeServerDelete) {
t.Fatalf("legacy mcp:fs:delete must NOT grant nezha:server:delete via HasScope")
}
}
func TestHasScope_LegacyMCPAllNoLongerWildcards(t *testing.T) {
tok := &APIToken{ScopesCSV: "mcp:*"}
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
if tok.HasScope(s) {
t.Errorf("legacy mcp:* must not be treated as a nezha:* wildcard; granted %s", s)
}
}
}
func TestHasScope_NezhaWildcardStillWorks(t *testing.T) {
tok := &APIToken{ScopesCSV: ScopeNezhaAll}
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
if !tok.HasScope(s) {
t.Errorf("nezha:* wildcard must still cover %s", s)
}
}
}
func TestHasScope_NezhaResourceWildcardStillWorks(t *testing.T) {
tok := &APIToken{ScopesCSV: "nezha:server:*"}
for _, s := range []string{ScopeServerRead, ScopeServerWrite, ScopeServerDelete, ScopeServerExec} {
if !tok.HasScope(s) {
t.Errorf("nezha:server:* must cover %s", s)
}
}
if tok.HasScope(ScopeServiceRead) {
t.Fatalf("nezha:server:* must NOT leak into nezha:service:* family")
}
}
+5
View File
@@ -17,8 +17,13 @@ const (
CtxKeyAuthorizedUser = "ckau"
CtxKeyRealIPStr = "ckri"
CtxKeyIsIPMismatch = "ckipm"
CtxKeyAPIToken = "ckpat"
)
type APITokenAccessor interface {
CanAccessServer(uint64) bool
}
const (
CacheKeyOauth2State = "cko2s::"
)
+23
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"github.com/go-viper/mapstructure/v2"
kmaps "github.com/knadh/koanf/maps"
@@ -51,6 +52,8 @@ type ConfigDashboard struct {
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist
// IP变更提醒
EnableIPChangeNotification bool `koanf:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
IPChangeNotificationGroupID uint64 `koanf:"ip_change_notification_group_id" json:"ip_change_notification_group_id"`
@@ -80,6 +83,11 @@ type Config struct {
jwtSecretFromEnv bool `koanf:"-" json:"-" yaml:"-"`
jwtSecretFromYAML bool `koanf:"-" json:"-" yaml:"-"`
// mcpEnabledEnableMCP 的并发安全镜像,kill switch 跨 goroutine 读写走
// MCPEnabled()/SetMCPEnabled()。放外层 Config 而非 ConfigDashboard,避免
// SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。
mcpEnabled atomic.Bool `koanf:"-" json:"-" yaml:"-"`
// oauth2 配置
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
@@ -212,9 +220,23 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
}
}
c.mcpEnabled.Store(c.EnableMCP)
return nil
}
// MCPEnabled 并发安全地读取 MCP kill switch 状态。
func (c *Config) MCPEnabled() bool {
return c.mcpEnabled.Load()
}
// SetMCPEnabled 并发安全地更新 MCP kill switch 状态。只写 atomic 镜像,不直接
// 写 EnableMCP 明文字段——后者会与 listConfig 的 *singleton.Conf 整体拷贝读发生
// 数据竞争。持久化由 save() 在 marshal 前从 atomic 同步明文字段完成。
func (c *Config) SetMCPEnabled(v bool) {
c.mcpEnabled.Store(v)
}
// Save 保存配置文件
func (c *Config) Save() error {
return c.save()
@@ -282,6 +304,7 @@ func (c *Config) patchYAMLField(key string, value any) error {
}
func (c *Config) save() error {
c.EnableMCP = c.mcpEnabled.Load()
data, err := yaml.Marshal(c)
if err != nil {
return err
+42
View File
@@ -3,6 +3,7 @@ package model
import (
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
@@ -45,3 +46,44 @@ func (c *Cron) BeforeSave(tx *gorm.DB) error {
func (c *Cron) AfterFind(tx *gorm.DB) error {
return json.Unmarshal([]byte(c.ServersRaw), &c.Servers)
}
// HasPermission 扩展默认的 owner/admin 检查,使得 PAT 的 server_ids 白名单
// 同样能收窄 cron 的列出、触发、删除路径。
//
// 语义按 Cover 字段分流,与 dispatch 入口(CronTrigger)的 fan-out 规则严格
// 对齐——Servers 字段在不同 Cover 下含义完全相反:
//
// - CronCoverIgnoreAllServers 是 allow-list;必须每个 server 都落在 PAT
// 白名单内。空 allow-list 是「matches nothing」的退化形态,安全。
// - CronCoverAlertTriggerServers 是触发服务器 allow-list;与上同。
// - CronCoverAllServers 是 deny-list。dispatch 时 fan out 到 owner 的
// 全部 server 再减去这个 deny-list。受限 PAT 必须保证 deny-list 已经覆
// 盖 owner 在白名单外的所有 servers——否则 CronTrigger 会把任务发到
// PAT 没权限的 server 上。本方法和 controller 写侧 guard
// rejectImplicitCoverForLimitedPAT* / 运行时 guard
// enforcePATCronDispatchScope 共用 DenyListSafeForLimitedPAT,避免列表
// 视图把越界历史/旁路写入行漏给受限 PAT。
func (c *Cron) HasPermission(ctx *gin.Context) bool {
if !c.Common.HasPermission(ctx) {
return false
}
v, ok := ctx.Get(CtxKeyAPIToken)
if !ok {
return true
}
tok, _ := v.(APITokenAccessor)
if tok == nil {
return true
}
switch c.Cover {
case CronCoverAll:
return DenyListSafeForLimitedPAT(tok, c.GetUserID(), c.Servers)
default:
for _, id := range c.Servers {
if !tok.CanAccessServer(id) {
return false
}
}
return true
}
}
+116
View File
@@ -0,0 +1,116 @@
package model
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// C1 regression: an admin-owned CronCoverAll fans out to every server in the
// system at runtime (CronTrigger gates on userIsAdmin(cr.UserID)). A
// server-limited PAT created by that admin must therefore only pass
// HasPermission when its deny-list covers EVERY server outside its whitelist
// system-wide — not just the admin's own servers, which is the visibly
// degenerate set that OwnerServerIDsLookup returns today.
//
// Without this regression, an admin with a PAT scoped to server X can create
// a CronCoverAll cron with deny-list = [X] and the dashboard will cheerfully
// dispatch the command to every OTHER user's server.
func TestCronHasPermission_AdminOwnerCoverAllDeniesUntilDenyListCoversAllOtherServers(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
// Admin (uid=1) owns only server 1. Member (uid=200) owns server 2.
OwnerServerIDsLookup = func(ownerUID uint64) []uint64 {
switch ownerUID {
case 1:
return []uint64{1}
case 200:
return []uint64{2}
}
return nil
}
OwnerIsAdminLookup = func(uid uint64) bool { return uid == 1 }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2} }
// Limited PAT (admin's) — scoped to server 1 only.
pat := &stubPATAccessor{ids: []uint64{1}}
t.Run("deny_list_missing_other_owner_server_must_reject", func(t *testing.T) {
cron := &Cron{
Common: Common{ID: 9, UserID: 1}, // admin-owned
Cover: CronCoverAll,
Servers: []uint64{1}, // deny self, but NOT server 2 (member's)
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 1}, Role: RoleAdmin})
ctx.Set(CtxKeyAPIToken, pat)
if cron.HasPermission(ctx) {
t.Fatal("admin-owned CronCoverAll fans out to ALL servers at runtime; " +
"deny-list missing server 2 must reject a PAT scoped to [1] — otherwise " +
"the cron will execute on a foreign user's server")
}
})
t.Run("deny_list_covers_all_other_servers_passes", func(t *testing.T) {
cron := &Cron{
Common: Common{ID: 10, UserID: 1},
Cover: CronCoverAll,
Servers: []uint64{2}, // deny the only server outside PAT whitelist
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 1}, Role: RoleAdmin})
ctx.Set(CtxKeyAPIToken, pat)
if !cron.HasPermission(ctx) {
t.Fatal("deny-list covering every non-whitelisted server must pass: fan-out " +
"is now contained inside the PAT whitelist")
}
})
}
// Companion: member-owned crons must NOT use the system-wide fan-out set.
// Runtime CronTrigger only ships to servers whose UserID matches the
// member-owner; HasPermission must mirror that to avoid false rejects on
// completely legitimate configs.
func TestCronHasPermission_MemberOwnerCoverAllStillUsesOwnerSet(t *testing.T) {
saved := OwnerServerIDsLookup
savedAdmin := OwnerIsAdminLookup
savedAll := AllServerIDsLookup
t.Cleanup(func() {
OwnerServerIDsLookup = saved
OwnerIsAdminLookup = savedAdmin
AllServerIDsLookup = savedAll
})
OwnerServerIDsLookup = func(ownerUID uint64) []uint64 {
if ownerUID == 100 {
return []uint64{1}
}
return nil
}
OwnerIsAdminLookup = func(uid uint64) bool { return false }
AllServerIDsLookup = func() []uint64 { return []uint64{1, 2, 3} }
cron := &Cron{
Common: Common{ID: 9, UserID: 100},
Cover: CronCoverAll,
Servers: []uint64{}, // empty deny-list: fan-out = owner-set = [1]
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
// PAT whitelist = [1] — covers everything the member owner can fan out to.
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !cron.HasPermission(ctx) {
t.Fatal("member-owned CronCoverAll fans out to owner servers only; PAT [1] covers them all")
}
}
+102
View File
@@ -0,0 +1,102 @@
package model
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// stubPATAccessor 是只在测试里用的最小 APITokenAccessor,仅按 ids
// 字面包含判断。够用就行,不引入 *APIToken 在 model 包里转译 CSV。
type stubPATAccessor struct {
ids []uint64
}
func (s *stubPATAccessor) CanAccessServer(id uint64) bool {
for _, x := range s.ids {
if x == id {
return true
}
}
return false
}
// ServerIDs 暴露白名单,使 DenyListSafeForLimitedPAT 能区分「unscoped PAT」
// 与「server-limited PAT」;缺这个方法时所有 stub 都会被当作不受限放行。
func (s *stubPATAccessor) ServerIDs() []uint64 {
return s.ids
}
// 钉死「server-limited PAT 不能通过 cover-all + 空 Servers 越过白名单」。
// 老实现在 len(c.Servers)==0 时直接放行,但 CronCoverAll + 空 Servers 在
// CronTrigger 里会 fan out 到 owner 的所有 server(包含白名单外的)。
// HasPermission 是 cron 列表/手动触发/删除路径上唯一的 PAT 收口,
// 因此这里必须拒绝。
func TestCronHasPermission_DeniesCoverAllEmptyServersForLimitedPAT(t *testing.T) {
cron := &Cron{
Common: Common{ID: 9, UserID: 100},
Cover: CronCoverAll,
Servers: nil,
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if cron.HasPermission(ctx) {
t.Fatal("server-limited PAT must not be allowed to operate on a CronCoverAll cron with empty Servers")
}
}
// CoverIgnoreAll + 空 Servers 在 CronTrigger 里是 “allow-list of zero”,
// 不会 fan out。允许 PAT 继续看到/触发是无害的,但 HasPermission 的
// 老语义在这一组合下仍是 return true,所以这条测试是「保持现状」的金线,
// 防止未来收紧时把这一无害情况也误拒。
func TestCronHasPermission_AllowsCoverIgnoreAllEmptyServersForLimitedPAT(t *testing.T) {
cron := &Cron{
Common: Common{ID: 10, UserID: 100},
Cover: CronCoverIgnoreAll,
Servers: nil,
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !cron.HasPermission(ctx) {
t.Fatal("CronCoverIgnoreAll + empty Servers is a no-op cron; server-limited PAT must remain allowed")
}
}
// 现有 non-empty Servers 路径必须保持不变:白名单内允许、白名单外拒绝。
// 这条用例钉死「修复 cover-all 路径时不能误改这条已有的金线」。
func TestCronHasPermission_KeepsExistingNonEmptyServersSemantics(t *testing.T) {
t.Run("whitelisted", func(t *testing.T) {
cron := &Cron{
Common: Common{ID: 11, UserID: 100},
Cover: CronCoverIgnoreAll,
Servers: []uint64{1},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !cron.HasPermission(ctx) {
t.Fatal("cron bound to whitelisted server 1 must remain accessible to PAT [1]")
}
})
t.Run("outside whitelist", func(t *testing.T) {
cron := &Cron{
Common: Common{ID: 12, UserID: 100},
Cover: CronCoverIgnoreAll,
Servers: []uint64{2},
}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if cron.HasPermission(ctx) {
t.Fatal("cron bound to non-whitelisted server 2 must be rejected for PAT [1]")
}
})
}
+42
View File
@@ -0,0 +1,42 @@
package model
import "time"
// MCPAuditLog 记录每一次 MCP tool 调用,用于事后追责与异常检测。
// 写入是 best-effort:失败仅打日志,不阻塞业务请求。
type MCPAuditLog struct {
ID uint64 `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `gorm:"index" json:"created_at"`
UserID uint64 `gorm:"index" json:"user_id"`
TokenID uint64 `gorm:"index" json:"token_id"`
Tool string `gorm:"type:varchar(64);index" json:"tool"`
ServerID uint64 `gorm:"index" json:"server_id,omitempty"`
ArgsHash string `gorm:"type:char(64)" json:"args_hash"`
ArgsPeek string `gorm:"type:varchar(512)" json:"args_peek,omitempty"`
Outcome string `gorm:"type:varchar(32);index" json:"outcome"`
ErrorCode string `gorm:"type:varchar(32)" json:"error_code,omitempty"`
ErrorMsg string `gorm:"type:varchar(512)" json:"error_msg,omitempty"`
DurationMs int64 `json:"duration_ms"`
IP string `gorm:"type:varchar(64)" json:"ip"`
}
func (MCPAuditLog) TableName() string {
return "mcp_audit_logs"
}
const (
MCPOutcomeOK = "ok"
MCPOutcomeScopeDenied = "scope_denied"
MCPOutcomePermDenied = "permission_denied"
MCPOutcomeServerOffline = "server_offline"
MCPOutcomeAgentTimeout = "agent_timeout"
MCPOutcomeAgentError = "agent_error"
// MCPOutcomeMCPDisabled 区分 “管理员按下 kill switch 把 MCP 关了” 与
// “agent 真出故障” 两种语义:前者属 forbidden 类、不应该触发 agent
// 故障告警;详见 service/rpc/mcp_rpc.go 里 ErrMCPDisabled 的注释。
MCPOutcomeMCPDisabled = "mcp_disabled"
MCPOutcomeInvalidArgs = "invalid_args"
MCPOutcomeRateLimited = "rate_limited"
MCPOutcomeUnsupportedAgent = "unsupported_agent"
MCPOutcomeInternalError = "internal_error"
)
+39
View File
@@ -0,0 +1,39 @@
package model
import "testing"
func TestSetMCPEnabledUsesAtomicAsSourceOfTruth(t *testing.T) {
c := &Config{}
if c.MCPEnabled() {
t.Fatal("zero-value Config must report MCP disabled")
}
c.SetMCPEnabled(true)
if !c.MCPEnabled() {
t.Fatal("MCPEnabled() must observe SetMCPEnabled(true)")
}
c.SetMCPEnabled(false)
if c.MCPEnabled() {
t.Fatal("MCPEnabled() must observe SetMCPEnabled(false)")
}
}
// save() 在 marshal 前从 atomic 同步明文 EnableMCP 字段,因此持久化/JSON 仍拿到
// 正确值;运行时 SetMCPEnabled 不直接写该字段以避免与 listConfig 的整体拷贝竞争。
func TestSaveSyncsEnableMCPFieldFromAtomic(t *testing.T) {
c := &Config{}
c.filePath = t.TempDir() + "/config.yaml"
c.SetMCPEnabled(true)
if err := c.save(); err != nil {
t.Fatalf("save: %v", err)
}
if !c.EnableMCP {
t.Fatal("save() must sync EnableMCP field from the atomic mirror for persistence")
}
c.SetMCPEnabled(false)
if err := c.save(); err != nil {
t.Fatalf("save: %v", err)
}
if c.EnableMCP {
t.Fatal("save() must clear EnableMCP field when the atomic mirror is false")
}
}
+19
View File
@@ -1,5 +1,7 @@
package model
import "github.com/gin-gonic/gin"
type NAT struct {
Common
Enabled bool `json:"enabled"`
@@ -8,3 +10,20 @@ type NAT struct {
Host string `json:"host"`
Domain string `json:"domain" gorm:"unique"`
}
// HasPermission 在 owner/admin 之上叠加 PAT 的 server_ids 白名单,
// 与 Server/Service/Cron.HasPermission 一致,避免 server-limited PAT 越权。
func (n *NAT) HasPermission(ctx *gin.Context) bool {
if !n.Common.HasPermission(ctx) {
return false
}
v, ok := ctx.Get(CtxKeyAPIToken)
if !ok {
return true
}
tok, ok := v.(APITokenAccessor)
if !ok || tok == nil {
return true
}
return tok.CanAccessServer(n.ServerID)
}
+55
View File
@@ -0,0 +1,55 @@
package model
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
// Regression: NAT had no HasPermission override, so it fell back to
// Common.HasPermission (owner/admin only). listHandler/CheckPermission gate
// on NAT.HasPermission, meaning a server-limited PAT could list/update/delete
// NAT records bound to off-whitelist servers of the same owner. NAT now
// applies CanAccessServer(NAT.ServerID) like Server/Service/Cron.
func TestNATHasPermission_DeniesOffWhitelistServerForLimitedPAT(t *testing.T) {
nat := &NAT{Common: Common{ID: 1, UserID: 100}, ServerID: 2}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}}) // whitelist excludes server 2
if nat.HasPermission(ctx) {
t.Fatal("server-limited PAT must not reach a NAT bound to an off-whitelist server")
}
}
func TestNATHasPermission_AllowsWhitelistedServerForLimitedPAT(t *testing.T) {
nat := &NAT{Common: Common{ID: 1, UserID: 100}, ServerID: 1}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
ctx.Set(CtxKeyAPIToken, &stubPATAccessor{ids: []uint64{1}})
if !nat.HasPermission(ctx) {
t.Fatal("PAT whitelisted to the NAT's server must be allowed")
}
}
func TestNATHasPermission_NoPATPassesViaCommonHasPermission(t *testing.T) {
nat := &NAT{Common: Common{ID: 1, UserID: 100}, ServerID: 2}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
if !nat.HasPermission(ctx) {
t.Fatal("owner without PAT must keep the existing owner/admin pass")
}
}
func TestNATHasPermission_DeniesNonOwner(t *testing.T) {
nat := &NAT{Common: Common{ID: 1, UserID: 100}, ServerID: 1}
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 200}, Role: RoleMember})
if nat.HasPermission(ctx) {
t.Fatal("a different non-admin user must not reach another owner's NAT")
}
}
+168 -5
View File
@@ -1,11 +1,14 @@
package model
import (
"errors"
"log"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"gorm.io/gorm"
@@ -38,7 +41,12 @@ type Server struct {
// handler that reassigns the stream on every reconnect — a torn read of the
// two-word interface header would panic on a subsequent .Send call. The
// atomic.Pointer + holder struct lets us swap the stream lock-free while
// every reader observes a single, consistent value.
// every reader observes a single, consistent value. The holder also carries
// the send mutex so CopyFromRunningServer can share it across the old/new
// *Server objects that briefly co-exist during edit/transfer rotations —
// otherwise two *Server pointers would hold the same gRPC stream behind
// two independent mutexes, defeating the "one SendMsg goroutine per stream"
// invariant grpc-go requires.
taskStream atomic.Pointer[taskStreamHolder]
ConfigCache chan any `gorm:"-" json:"-"`
@@ -51,8 +59,14 @@ type Server struct {
// field `TaskStream pb.NezhaService_RequestTaskServer` was a plain interface
// value: two words on the heap (type ptr + data ptr). Concurrent assignment
// produced torn reads detectable by `go test -race` and crashable in production.
//
// sendMu lives on the holder (not on *Server) so it is bound to the stream
// itself: CopyFromRunningServer shares the same holder pointer with the new
// *Server, and SendTask locks via the holder, guaranteeing serialized SendMsg
// even when old/new *Server objects briefly co-exist during edit/transfer.
type taskStreamHolder struct {
s pb.NezhaService_RequestTaskServer
s pb.NezhaService_RequestTaskServer
sendMu sync.Mutex
}
// SetTaskStream publishes the agent's RequestTask stream so other goroutines
@@ -65,6 +79,13 @@ func (s *Server) SetTaskStream(stream pb.NezhaService_RequestTaskServer) {
s.taskStream.Store(&taskStreamHolder{s: stream})
}
// adoptTaskStreamHolder publishes an existing holder verbatim. Used by
// CopyFromRunningServer so the new *Server shares the send mutex (and the
// underlying stream identity) with the old *Server.
func (s *Server) adoptTaskStreamHolder(h *taskStreamHolder) {
s.taskStream.Store(h)
}
// ClearTaskStreamIfCurrent detaches stream only if it is still the published
// RequestTask stream. Disconnect cleanup uses this guard so an old stream
// returning after a reconnect cannot erase the newer live stream.
@@ -95,6 +116,31 @@ func (s *Server) GetTaskStream() pb.NezhaService_RequestTaskServer {
return h.s
}
// SendTask dispatches a task on the agent's RequestTask stream under the
// holder's sendMu so concurrent dispatchers (cron, server-transfer
// ApplyConfig, MCP CallAgent, MCP fs.transfer, force-update, report-config)
// cannot violate grpc-go's "one SendMsg goroutine per stream" rule. Returns
// ErrTaskStreamOffline if the agent has not published a stream yet; callers
// that need to distinguish offline from send failure should branch on that.
//
// The mutex is keyed by holder (= by stream) rather than by *Server so that
// edit/transfer rotations replacing *Server in the singleton map still share
// a single lock across the old and new objects pointing at the same stream.
func (s *Server) SendTask(task *pb.Task) error {
h := s.taskStream.Load()
if h == nil {
return ErrTaskStreamOffline
}
h.sendMu.Lock()
defer h.sendMu.Unlock()
return h.s.Send(task)
}
// ErrTaskStreamOffline is returned by SendTask when the agent has no
// published RequestTask stream. Defined here (rather than in service/rpc)
// so model-layer callers can branch on it without an import cycle.
var ErrTaskStreamOffline = errors.New("agent task stream offline")
func InitServer(s *Server) {
s.Host = &Host{}
s.State = &HostState{}
@@ -107,9 +153,12 @@ func (s *Server) CopyFromRunningServer(old *Server) {
s.State = old.State
s.GeoIP = old.GeoIP
s.LastActive = old.LastActive
// taskStream is an atomic.Pointer; copy the published value rather than
// the field itself (atomic.Pointer is not safe to copy by value).
s.SetTaskStream(old.GetTaskStream())
// Adopt the holder pointer verbatim so the new *Server shares the send
// mutex AND the stream identity with the old *Server; constructing a fresh
// holder via SetTaskStream(GetTaskStream()) would give the new object its
// own mutex, letting two *Server pointers race SendMsg on the same stream
// during the edit/transfer rotation window.
s.adoptTaskStreamHolder(old.taskStream.Load())
s.ConfigCache = old.ConfigCache
s.PrevTransferInSnapshot = old.PrevTransferInSnapshot
s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot
@@ -147,6 +196,39 @@ type ServerOwnerInfo struct {
// in tests / headless contexts so the JSON simply omits the owner field.
var ServerOwnerLookup func(uid uint64) (ServerOwnerInfo, bool)
// OwnerServerIDsLookup is installed by singleton at startup to enumerate the
// IDs of every in-memory Server whose UserID == ownerUID. It exists so that
// Cron.HasPermission / Service.HasPermission can faithfully replay the
// dispatch-side "CoverAll deny-list must cover every PAT-whitelisted-out
// owner server" rule without depending on controller helpers (model must
// not import service/singleton — cycle).
//
// Left nil in tests / headless contexts; callers MUST treat a nil hook as
// "unknown owner topology" and fall back to a conservative decision (the
// existing model.Cron / model.Service code rejects non-trivial CoverAll
// configs for limited PATs when the hook is nil, matching the historical
// behaviour for empty deny-lists).
var OwnerServerIDsLookup func(ownerUID uint64) []uint64
// OwnerIsAdminLookup reports whether ownerUID is an admin user. When the
// owner is admin the runtime dispatch path (CronTrigger, DispatchTask) gates
// on userIsAdmin(cr.UserID) / userIsAdmin(svc.UserID) and fans out across
// EVERY in-memory server — not just the owner's. DenyListSafeForLimitedPAT
// must mirror that fan-out widening or a limited PAT can pass safety check
// with a deny-list that covers only the admin's own servers while the
// runtime still ships the task to foreign-owned servers.
//
// Left nil in tests / headless contexts; callers fall back to
// "owner-set only" which matches the pre-C1 behaviour.
var OwnerIsAdminLookup func(ownerUID uint64) bool
// AllServerIDsLookup returns every in-memory server ID, regardless of
// owner. It is the system-wide fan-out set the runtime uses for
// admin-owned CoverAll cron/service dispatch and is the only correct
// containment set for a server-limited PAT operating on an admin-owned
// resource. Left nil in tests / headless contexts.
var AllServerIDsLookup func() []uint64
type serverJSON Server
type serverWithOwner struct {
@@ -175,6 +257,87 @@ func (s *Server) MarshalJSON() ([]byte, error) {
})
}
func (s *Server) HasPermission(ctx *gin.Context) bool {
if !s.Common.HasPermission(ctx) {
return false
}
v, ok := ctx.Get(CtxKeyAPIToken)
if !ok {
return true
}
tok, ok := v.(APITokenAccessor)
if !ok || tok == nil {
return true
}
return tok.CanAccessServer(s.GetID())
}
// APITokenWhitelistView is the optional shape an APITokenAccessor can
// implement so DenyListSafeForLimitedPAT can tell unscoped PATs (no
// whitelist → not limited) apart from server-limited ones. Accessors that
// do NOT expose ServerIDs() are treated as potentially limited; the safe
// dispatch path then requires denyList to cover every owner-visible server
// outside what the PAT can reach.
type APITokenWhitelistView interface {
ServerIDs() []uint64
}
// DenyListSafeForLimitedPAT reports whether a CoverAll/SkipServers deny-list
// keeps a server-limited PAT inside its server_ids whitelist. The runtime
// dispatch path (CronTrigger, DispatchTask) iterates every owner-visible
// server minus denyList; for the PAT to stay contained, every owner server
// outside its whitelist must already appear in denyList. JWT requests and
// PATs with no whitelist are unaffected. Nil OwnerServerIDsLookup forces
// the conservative "reject" branch instead of silently allowing a config
// the runtime would dispatch outside the whitelist.
func DenyListSafeForLimitedPAT(tok APITokenAccessor, ownerUID uint64, denyServers []uint64) bool {
if tok == nil {
return true
}
if wl, ok := tok.(APITokenWhitelistView); ok && len(wl.ServerIDs()) == 0 {
return true
}
fanout := ownerEffectiveFanoutServerIDs(ownerUID)
if fanout == nil {
return false
}
denySet := make(map[uint64]struct{}, len(denyServers))
for _, id := range denyServers {
denySet[id] = struct{}{}
}
for _, id := range fanout {
if tok.CanAccessServer(id) {
continue
}
if _, denied := denySet[id]; !denied {
return false
}
}
return true
}
// ownerEffectiveFanoutServerIDs returns the server set the runtime dispatch
// will actually fan out to for a resource owned by ownerUID. Admin owners
// short-circuit cronCanSendToServer / canSendServiceTask via userIsAdmin,
// so the safe containment set is the WHOLE system, not just the admin's
// own servers. Member owners stay bounded to their own server set.
//
// Returns nil to signal "topology unknown" — callers (DenyListSafeForLimitedPAT)
// fall back to fail-closed in that case, matching the historical conservative
// branch when OwnerServerIDsLookup was nil.
func ownerEffectiveFanoutServerIDs(ownerUID uint64) []uint64 {
if OwnerIsAdminLookup != nil && OwnerIsAdminLookup(ownerUID) {
if AllServerIDsLookup == nil {
return nil
}
return AllServerIDsLookup()
}
if OwnerServerIDsLookup == nil {
return nil
}
return OwnerServerIDsLookup(ownerUID)
}
func (s *Server) SplitList(x []*Server) ([]*Server, []*Server) {
pri := func(s *Server) bool {
return s.DisplayIndex == 0
+10
View File
@@ -81,11 +81,21 @@ type ServerTransfer struct {
// admins, the source user, the destination user, and the initiator. Listing
// uses this to filter what the caller can see; mutating endpoints (cancel,
// retry) layer additional checks on top.
//
// PAT server_ids whitelist is evaluated FIRST, before the admin short-
// circuit, so an admin-issued PAT scoped to a subset of servers cannot
// widen reach by virtue of the caller being an admin. JWT callers (no PAT
// in context) skip the whitelist check.
func (t *ServerTransfer) HasPermission(ctx *gin.Context) bool {
auth, ok := ctx.Get(CtxKeyAuthorizedUser)
if !ok {
return false
}
if v, ok := ctx.Get(CtxKeyAPIToken); ok {
if tok, _ := v.(APITokenAccessor); tok != nil && !tok.CanAccessServer(t.ServerID) {
return false
}
}
user := *auth.(*User)
if user.Role == RoleAdmin {
return true
+204
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"log"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
@@ -30,6 +31,158 @@ const (
// 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 下发到 agentagent 据此打开本地
// 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 typeTaskTypeFM 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 {
@@ -86,6 +239,57 @@ func (m *Service) PB() *pb.Task {
}
}
// HasPermission 扩展默认的 owner/admin 检查,让 PAT 的 server_ids 白名单
// 同样能收窄 service monitor 的列出/删除/更新路径,语义与 Cron.HasPermission
// 对齐:
// - ServiceCoverAllSkipServers 是 deny-set。DispatchTask 会探测 owner 在
// deny-set 之外的所有 server,所以受限 PAT 必须保证 deny-set 已经覆盖
// 白名单外的全部 owner servers。判定与 controller 的
// enforcePATServiceDispatchScope / rejectImplicitServiceCoverForLimitedPAT
// 共用 denyListSafeForLimitedPAT。
// - ServiceCoverIgnoreAllSkipServers 是 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 {
+51
View File
@@ -0,0 +1,51 @@
package model
import (
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func adminPATCtx(tok *APIToken) *gin.Context {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("GET", "/", nil)
c.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 1}, Role: RoleAdmin})
c.Set(CtxKeyAPIToken, tok)
return c
}
// ServiceCoverIgnoreAll permission must only consider SkipServers entries
// whose value is true (the allow-set actually dispatched at runtime). A
// `{2: false}` entry has no dispatch effect, so a PAT scoped to {1} must
// still be allowed to manage this service.
func TestServiceHasPermissionIgnoreAllSkipsFalseEntries(t *testing.T) {
tok := &APIToken{ID: 1, UserID: 1}
tok.SetServerIDs([]uint64{1})
svc := &Service{
Common: Common{ID: 10, UserID: 1},
Cover: ServiceCoverIgnoreAll,
SkipServers: map[uint64]bool{1: true, 2: false},
}
if !svc.HasPermission(adminPATCtx(tok)) {
t.Fatal("a `{2: false}` allow-set entry must not block a PAT scoped to {1}")
}
}
func TestServiceHasPermissionIgnoreAllRejectsForeignTrueEntry(t *testing.T) {
tok := &APIToken{ID: 1, UserID: 1}
tok.SetServerIDs([]uint64{1})
svc := &Service{
Common: Common{ID: 10, UserID: 1},
Cover: ServiceCoverIgnoreAll,
SkipServers: map[uint64]bool{2: true},
}
if svc.HasPermission(adminPATCtx(tok)) {
t.Fatal("a true allow-set entry on server 2 must reject a PAT scoped to {1}")
}
}
+1
View File
@@ -17,6 +17,7 @@ type SettingForm struct {
AgentTLS bool `json:"tls,omitempty" validate:"optional"`
EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
EnableMCP *bool `json:"enable_mcp,omitempty" validate:"optional"`
}
type Setting struct {