Merge branch 'upstream/master' into master and preserve domain extensions

This commit is contained in:
Bot
2026-08-31 02:46:42 +08:00
758 changed files with 89269 additions and 1366 deletions
+86
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))
@@ -109,6 +166,13 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
continue
} else {
// 常规报警
// duration<=0 是无意义的规则(持续 0 秒):直接跳过该规则,
// 既不污染 hasPassedRule(否则会连带跳过同一 alert 里其它有效
// 规则),也避免下方 fail*100/total 在 total=0 时整数除零 panic
// —— checkStatus 无 recover,一次 panic 会拖垮整个告警 goroutine。
if duration <= 0 {
continue
}
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
continue
}
@@ -132,6 +196,28 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
return slices.Max(durations), hasPassedRule
}
// RetentionWindow 返回保留历史采样所需的长度(各规则窗口的最大值),只依赖
// 规则定义而非 Check 的判定结果——否则窗口未填满时 Check 返回的 max=0 会被
// 误判为"无需历史"而清空采样,使规则永远攒不够样本。
// 各规则类型回看的采样数必须与 Check 中实际读取的窗口一致:
// - 周期流量规则:Check 只读最后 1 个采样点 → 需要 1
// - 离线规则、常规规则:Check 读取 points[len-Duration:] → 需要 Duration
func (r *AlertRule) RetentionWindow() int {
window := 0
for _, rule := range r.Rules {
var need int
if rule.IsTransferDurationRule() {
need = 1
} else if d := int(rule.Duration); d > 0 {
need = d
}
if need > window {
window = need
}
}
return window
}
func boundCheck(length, duration int, passed bool) bool {
if passed {
return true
+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")
}
}
+175
View File
@@ -315,3 +315,178 @@ func assertEq(t *testing.T, msg string, exp, act any) {
t.Fatalf("failed to test for %s. exp=[%v] but act=[%v]", msg, exp, act)
}
}
// TestAlertRule_ZeroDurationGeneralRule guards against a config-reachable DoS:
// a general rule with Duration:0 (the API validates Duration as "optional" with
// no minimum) previously hit fail*100/total with total==0, panicking with an
// integer divide-by-zero inside checkStatus — which has no recover and would
// take down the whole alert goroutine. boundCheck now treats duration<=0 as a
// passed (no-op) rule, so Check must return without panicking.
func TestAlertRule_ZeroDurationGeneralRule(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Check panicked on a Duration:0 general rule (config-reachable DoS): %v", r)
}
}()
rule := &AlertRule{
Rules: []*Rule{{Type: "cpu", Duration: 0}},
}
// The only contract here is "do not panic". A zero-duration rule is skipped,
// so it contributes nothing to the verdict and max stays 0.
maxD, _ := rule.Check([][]bool{{true}, {false}})
if maxD != 0 {
t.Fatalf("a skipped Duration:0 rule must not contribute to max, got %d", maxD)
}
}
// Mixing a valid rule with a zero-duration rule must also be safe: the zero
// rule is skipped, the real rule still drives the verdict.
func TestAlertRule_ZeroDurationMixedWithValidRule(t *testing.T) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Check panicked on a mixed zero/valid rule set: %v", r)
}
}()
rule := &AlertRule{
Rules: []*Rule{
{Type: "cpu", Duration: 0},
{Type: "cpu", Duration: 3},
},
}
maxD, _ := rule.Check([][]bool{{true, false}, {true, false}, {true, false}})
if maxD != 3 {
t.Fatalf("the valid Duration:3 rule must still set max=3, got %d", maxD)
}
}
// trimSamples mirrors singleton.checkStatus retention: keep the most recent
// `window` samples, clear when window<=0. window comes from RetentionWindow(),
// the production code under test.
func trimSamples(samples [][]bool, window int) [][]bool {
if window <= 0 {
return samples[:0]
} else if window < len(samples) {
return samples[len(samples)-window:]
}
return samples
}
// TestAlertRule_GeneralRuleAccumulatesSamples is a regression guard: a normal
// Duration>1 general rule must be able to fire. checkStatus appends one sample
// per tick then trims to the retention window; if the window is derived from
// Check's verdict (which is 0 while the rule is still filling) the history is
// wiped every tick, the window never reaches Duration, and the alert never
// raises. RetentionWindow() must keep enough samples for the rule to converge.
func TestAlertRule_GeneralRuleAccumulatesSamples(t *testing.T) {
const duration = 10
rule := &AlertRule{
Rules: []*Rule{{Type: "cpu", Duration: duration}},
}
var samples [][]bool
var lastPassed bool
maxLen := 0
for tick := 0; tick < duration*3; tick++ {
samples = append(samples, []bool{false}) // failing sample
_, lastPassed = rule.Check(samples)
samples = trimSamples(samples, rule.RetentionWindow())
if len(samples) > maxLen {
maxLen = len(samples)
}
}
if maxLen < duration {
t.Fatalf("samples never accumulated to Duration: max window reached %d, want >= %d", maxLen, duration)
}
if lastPassed {
t.Fatalf("a server failing every tick must eventually fail the check (passed=false), got passed=true")
}
}
// TestAlertRule_RetentionWindow pins the retention contract directly.
func TestAlertRule_RetentionWindow(t *testing.T) {
cases := []struct {
msg string
rule *AlertRule
want int
}{
{"single general", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 10}}}, 10},
{"zero duration only", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}}}, 0},
{"mixed picks max", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}, {Type: "cpu", Duration: 7}}}, 7},
{"offline keeps Duration", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 30}}}, 30},
{"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}}}, 1},
}
for _, c := range cases {
if got := c.rule.RetentionWindow(); got != c.want {
t.Fatalf("%s: RetentionWindow()=%d want %d", c.msg, got, c.want)
}
}
}
// TestAlertRule_OfflineRuleAccumulatesSamples is a regression guard for offline
// alerts that never fire. Check's offline branch reads points[len-Duration:],
// so it needs Duration samples retained; if RetentionWindow trims to 1 (as an
// earlier fix wrongly did for offline rules), the window never reaches Duration,
// boundCheck keeps returning "passed", and the offline alert never raises.
func TestAlertRule_OfflineRuleAccumulatesSamples(t *testing.T) {
const duration = 10
rule := &AlertRule{Rules: []*Rule{{Type: "offline", Duration: duration}}}
var samples [][]bool
var lastPassed bool
maxLen := 0
for tick := 0; tick < duration*3; tick++ {
samples = append(samples, []bool{false}) // offline sample
_, lastPassed = rule.Check(samples)
samples = trimSamples(samples, rule.RetentionWindow())
if len(samples) > maxLen {
maxLen = len(samples)
}
}
if maxLen < duration {
t.Fatalf("offline samples never accumulated to Duration: max window reached %d, want >= %d", maxLen, duration)
}
if lastPassed {
t.Fatalf("a server offline every tick must eventually fail the offline check (passed=false), got passed=true")
}
}
// TestAlertRule_CombinedRuleAccumulatesSamples drives the real trim loop for
// mixed-type alerts. The verdict is AND-of-failure: an incident fires only once
// every rule's lookback window is full and all fail. RetentionWindow must keep
// enough samples for the largest window (offline/general need Duration, cycle
// needs 1); if any rule type is under-retained the alert never fires.
func TestAlertRule_CombinedRuleAccumulatesSamples(t *testing.T) {
cases := []struct {
msg string
rule *AlertRule
sample []bool
fireAt int // tick index where passed must first become false
wantWindow int
}{
{"general3+general10", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 3}, {Type: "memory", Duration: 10}}}, []bool{false, false}, 9, 10},
{"offline5+general10", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 5}, {Type: "cpu", Duration: 10}}}, []bool{false, false}, 9, 10},
{"transfer+general8", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}, {Type: "cpu", Duration: 8}}}, []bool{false, false}, 7, 8},
{"offline3+offline12", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 3}, {Type: "offline", Duration: 12}}}, []bool{false, false}, 11, 12},
}
for _, c := range cases {
if got := c.rule.RetentionWindow(); got != c.wantWindow {
t.Fatalf("%s: RetentionWindow()=%d want %d", c.msg, got, c.wantWindow)
}
var samples [][]bool
firstFire := -1
for tick := 0; tick < c.wantWindow*3; tick++ {
samples = append(samples, append([]bool(nil), c.sample...))
if _, passed := c.rule.Check(samples); !passed && firstFire < 0 {
firstFire = tick
}
samples = trimSamples(samples, c.rule.RetentionWindow())
}
if firstFire != c.fireAt {
t.Fatalf("%s: alert first fired at tick %d, want %d (never-firing = -1)", c.msg, firstFire, c.fireAt)
}
}
}
+385
View File
@@ -0,0 +1,385 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"slices"
"strings"
"time"
"gorm.io/gorm"
)
// Scope 命名规范(唯一一套):nezha:{resource}:{verb}
//
// - resource: inventory / server / service / alertrule / cron / ddns / nat /
// notification / notification-group / transfer / admin
// - verb: read / write / delete / exec
//
// `*` 通配在 resource 或 verb 位均可:
// - nezha:server:* 给定资源的所有动作
// - nezha:* admin-only 全权
//
// inventory 与 server 已拆开:inventory 管“能看到/能删哪些机器”——`server.list`
// MCP tool、`GET /api/v1/server`、`/server-group`、batch-delete server/group 都用
// nezha:inventory:{read,delete}server 管对已知机器的运行态操作(server.get、
// exec、文件读写、编辑配置、metrics)。同一 scope 同时管 MCP tool 和 REST endpoint。
//
// 历史上还有 mcp:* 一套,会被 HasScope 通过别名映射到 nezha:server:* 子集。
// 由于 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:*"
// inventory 资源域:管理后台对“服务器清单”本身的枚举与删除(列出 GET /server、
// 删除 batch-delete/server、server-group 的列出/删除,以及 MCP server.list)。
// 刻意与 nezha:server:* 分开:后者是对已知 server 的运行态操作(exec / 文件读写 /
// 编辑 / metrics),而 inventory 是“能看到/能删哪些机器”的台账权限。拆开后,
// 一张只跑命令的 PAT 不必同时具备遍历和删除整个清单的能力。
ScopeInventoryRead = "nezha:inventory:read"
ScopeInventoryDelete = "nezha:inventory:delete"
ScopeServerRead = "nezha:server:read"
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" // #nosec G101 -- scope identifier, not a credential
ScopeNotificationGroupDelete = "nezha:notification-group:delete"
ScopeTransferRead = "nezha:transfer:read"
ScopeTransferWrite = "nezha:transfer:write"
ScopeTransferDelete = "nezha:transfer:delete"
ScopeAdminAll = "nezha:admin:*"
)
var AllScopes = []string{
ScopeInventoryRead, ScopeInventoryDelete,
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:inventory:*",
"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")
}
}
+28 -2
View File
@@ -6,6 +6,7 @@ import (
"slices"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
@@ -16,8 +17,13 @@ const (
CtxKeyAuthorizedUser = "ckau"
CtxKeyRealIPStr = "ckri"
CtxKeyIsIPMismatch = "ckipm"
CtxKeyAPIToken = "ckpat"
)
type APITokenAccessor interface {
CanAccessServer(uint64) bool
}
const (
CacheKeyOauth2State = "cko2s::"
)
@@ -37,8 +43,21 @@ func (c *Common) GetID() uint64 {
return c.ID
}
// GetUserID 原子读取所属用户 ID。Server.UserID 会在 ServerTransfer 的
// Register/revertTransition 流程里被实时改写以反映新所有者,同时 auth
// 热路径在每次 agent RPC 都会读它。任何并发读必须走 atomic,否则与 SetUserID
// 一起会被 go race detector 识别为 data race(见
// TestServerUserIDConcurrentAccessIsRaceFree)。
func (c *Common) GetUserID() uint64 {
return c.UserID
return atomic.LoadUint64(&c.UserID)
}
// SetUserID 原子改写所属用户 ID。仅在「server 已经在 in-memory cache 里」
// 的写入路径(ServerTransfer.Register / revertTransition)需要用 atomic
// 保证可见性;普通 GORM AfterFind / Create 因为没有并发读所以可以直接赋
// 值。配合 GetUserID 形成 atomic-only 的并发访问协议。
func (c *Common) SetUserID(uid uint64) {
atomic.StoreUint64(&c.UserID, uid)
}
func (c *Common) HasPermission(ctx *gin.Context) bool {
@@ -52,7 +71,14 @@ func (c *Common) HasPermission(ctx *gin.Context) bool {
return true
}
return user.ID == c.UserID
// 必须走 GetUserID 而不是裸读 c.UserID — Server.UserID 在
// ServerTransfer.Register / revertTransition 里会被 atomic.StoreUint64
// 改写,dashboard 各 controller 在 listHandler post-filter 这条热路径上
// 高频对同一 *Server 调 HasPermission。裸读会与 SetUserID 形成 data
// raceTestCommonHasPermissionConcurrentWithSetUserIDIsRaceFree 在
// -race 下钉死该不变量),并且在 transfer 切换瞬间可能给出错误的权限
// 判断。
return user.ID == c.GetUserID()
}
type CommonInterface interface {
+38
View File
@@ -1,11 +1,49 @@
package model
import (
"net/http/httptest"
"reflect"
"slices"
"testing"
"github.com/gin-gonic/gin"
)
func TestCommonHasPermission(t *testing.T) {
resource := &Common{ID: 10, UserID: 100}
t.Run("unauthenticated denied", func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
if resource.HasPermission(ctx) {
t.Fatal("expected unauthenticated request to be denied")
}
})
t.Run("owner allowed", func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 100}, Role: RoleMember})
if !resource.HasPermission(ctx) {
t.Fatal("expected owner to be allowed")
}
})
t.Run("foreign member denied", func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 200}, Role: RoleMember})
if resource.HasPermission(ctx) {
t.Fatal("expected non-owner member to be denied")
}
})
t.Run("admin allowed", func(t *testing.T) {
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 1}, Role: RoleAdmin})
if !resource.HasPermission(ctx) {
t.Fatal("expected admin to be allowed")
}
})
}
func TestSearchByID(t *testing.T) {
t.Run("WithoutPriorityList", func(t *testing.T) {
list, exp := []*DDNSProfile{
+168 -8
View File
@@ -1,9 +1,12 @@
package model
import (
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"github.com/go-viper/mapstructure/v2"
kmaps "github.com/knadh/koanf/maps"
@@ -15,9 +18,19 @@ import (
"github.com/nezhahq/nezha/pkg/utils"
)
// JWTSecretEnvKey is the canonical environment variable that injects the JWT
// signing key. When set, the dashboard never writes the key to disk and the
// version-driven rotation in RotateJWTSecretKeyIfNeeded is skipped so that
// rotation is fully controlled by the operator / KMS.
const JWTSecretEnvKey = "NZ_JWTSECRETKEY" // #nosec G101 -- environment variable name, not a hardcoded secret value.
const (
ConfigUsePeerIP = "NZ::Use-Peer-IP"
ConfigCoverAll = iota
ConfigUsePeerIP = "NZ::Use-Peer-IP"
JWTSecretKeyRotationBaselineVersion = "v2.0.13"
)
const (
ConfigCoverAll = iota + 1
ConfigCoverIgnoreAll
)
@@ -35,7 +48,17 @@ type ConfigForGuests struct {
type ConfigDashboard struct {
InstallHost string `koanf:"install_host" json:"install_host,omitempty"`
AgentTLS bool `koanf:"tls" json:"tls,omitempty"` // 用于前端判断生成的安装命令是否启用 TLS
// AgentTLS controls the transport emitted by Agent installation commands.
// false intentionally supports trusted private networks and does not provide
// Dashboard peer authentication; Internet-facing control planes must use
// verified TLS. Changing this compatibility default belongs in the installer
// migration path, not in the gRPC task authorization model.
AgentTLS bool `koanf:"tls" json:"tls,omitempty"`
// DashboardHost 是 dashboard 对外访问的主机名,专用于 OAuth2 回调地址。
// 它与 InstallHostagent 连接用主机名)解耦:两者可以是不同域名。
// 为空时,OAuth2 回调放行请求 Host(信任请求头),不做强制重写。
DashboardHost string `koanf:"dashboard_host" json:"dashboard_host,omitempty"`
WebRealIPHeader string `koanf:"web_real_ip_header" json:"web_real_ip_header,omitempty"` // 前端真实IP
AgentRealIPHeader string `koanf:"agent_real_ip_header" json:"agent_real_ip_header,omitempty"` // Agent真实IP
@@ -44,6 +67,13 @@ 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
// GHSA-x6fg-52vr-hj4w:反代部署下 dashboard 的对外域名进程自身看不到,
// InstallHost/ListenHost 无法覆盖。运维在此用逗号分隔声明这些对外 host,
// 成员便无法注册与之冲突的 NAT 域名抢占路由。
ReservedHosts string `koanf:"reserved_hosts" json:"reserved_hosts,omitempty"`
// 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"`
@@ -75,9 +105,18 @@ type Config struct {
AgentSecretKey string `koanf:"agent_secret_key" json:"agent_secret_key,omitempty"`
JWTTimeout int `koanf:"jwt_timeout" json:"jwt_timeout,omitempty"` // JWT token过期时间(小时)
JWTSecretKey string `koanf:"jwt_secret_key" json:"jwt_secret_key,omitempty"`
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"`
JWTSecretKey string `koanf:"jwt_secret_key" json:"-" yaml:"-"`
JWTSecretKeyLastRotatedVersion string `koanf:"jwt_secret_key_last_rotated_version" json:"jwt_secret_key_last_rotated_version,omitempty"`
ListenPort uint16 `koanf:"listen_port" json:"listen_port,omitempty"`
ListenHost string `koanf:"listen_host" json:"listen_host,omitempty"`
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"`
@@ -175,12 +214,23 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
if c.Cover == 0 {
c.Cover = 1
}
if envSecret := os.Getenv(JWTSecretEnvKey); envSecret != "" {
c.JWTSecretKey = envSecret
c.jwtSecretFromEnv = true
} else if c.JWTSecretKey != "" {
c.jwtSecretFromYAML = true
log.Printf("NEZHA>> jwt_secret_key loaded from config.yaml; recommend injecting via env %s to keep it off disk", JWTSecretEnvKey)
}
if c.JWTSecretKey == "" {
c.JWTSecretKey, err = utils.GenerateRandomString(1024)
generated, err := utils.GenerateRandomString(1024)
if err != nil {
return err
}
if err = c.Save(); err != nil {
c.JWTSecretKey = generated
c.jwtSecretFromYAML = true
log.Printf("NEZHA>> generated new jwt_secret_key; wrote to config.yaml. For production, inject via env %s and remove the field from config.yaml.", JWTSecretEnvKey)
if err := c.patchYAMLField("jwt_secret_key", generated); err != nil {
return err
}
}
@@ -200,15 +250,91 @@ 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()
}
func (c *Config) RotateJWTSecretKeyIfNeeded(currentVersion string) (bool, error) {
if c.jwtSecretFromEnv {
return false, nil
}
currentVersion = strings.TrimSpace(currentVersion)
if compareVersion(currentVersion, JWTSecretKeyRotationBaselineVersion) < 0 {
return false, nil
}
initialMarker := c.JWTSecretKeyLastRotatedVersion
shouldRotate := c.JWTSecretKeyLastRotatedVersion == "" || compareVersion(c.JWTSecretKeyLastRotatedVersion, JWTSecretKeyRotationBaselineVersion) < 0
if shouldRotate {
secret, err := utils.GenerateRandomString(1024)
if err != nil {
return false, err
}
c.JWTSecretKey = secret
}
c.JWTSecretKeyLastRotatedVersion = currentVersion
if !shouldRotate && c.JWTSecretKeyLastRotatedVersion == initialMarker {
return false, nil
}
if shouldRotate {
if err := c.patchYAMLField("jwt_secret_key", c.JWTSecretKey); err != nil {
return false, err
}
}
if err := c.patchYAMLField("jwt_secret_key_last_rotated_version", c.JWTSecretKeyLastRotatedVersion); err != nil {
return false, err
}
return shouldRotate, nil
}
func (c *Config) patchYAMLField(key string, value any) error {
dir := filepath.Dir(c.filePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
raw := map[string]any{}
if data, err := os.ReadFile(c.filePath); err == nil {
if len(data) > 0 {
if err := yaml.Unmarshal(data, &raw); err != nil {
return err
}
}
} else if !os.IsNotExist(err) {
return err
}
raw[key] = value
out, err := yaml.Marshal(raw)
if err != nil {
return err
}
return os.WriteFile(c.filePath, out, 0600)
}
func (c *Config) save() error {
c.EnableMCP = c.mcpEnabled.Load()
data, err := yaml.Marshal(c)
if err != nil {
return err
@@ -226,6 +352,40 @@ func (c *Config) write(data []byte) error {
return os.WriteFile(c.filePath, data, 0600)
}
func compareVersion(left, right string) int {
leftParts, leftOK := parseVersion(left)
rightParts, rightOK := parseVersion(right)
if !leftOK || !rightOK {
return -1
}
for i := range leftParts {
if leftParts[i] < rightParts[i] {
return -1
}
if leftParts[i] > rightParts[i] {
return 1
}
}
return 0
}
func parseVersion(version string) ([3]int, bool) {
version = strings.TrimPrefix(strings.TrimSpace(version), "v")
parts := strings.Split(version, ".")
if len(parts) != 3 {
return [3]int{}, false
}
var parsed [3]int
for i, part := range parts {
value, err := strconv.Atoi(part)
if err != nil {
return [3]int{}, false
}
parsed[i] = value
}
return parsed, true
}
func koanfConf(c any) koanf.UnmarshalConf {
return koanf.UnmarshalConf{
DecoderConfig: &mapstructure.DecoderConfig{
+118 -10
View File
@@ -110,17 +110,19 @@ func TestReadConfig(t *testing.T) {
})
t.Run("ReadEnvFile", func(t *testing.T) {
os.Setenv("NZ_JWTSECRETKEY", "test1")
os.Setenv("NZ_USERTEMPLATE", "um1")
os.Setenv("NZ_ADMINTEMPLATE", "am1")
os.Setenv("NZ_AGENTSECRETKEY", "none1")
os.Setenv("NZ_SITENAME", "lowkick1")
t.Setenv("NZ_JWTSECRETKEY", "test1")
t.Setenv("NZ_USERTEMPLATE", "um1")
t.Setenv("NZ_ADMINTEMPLATE", "am1")
t.Setenv("NZ_AGENTSECRETKEY", "none1")
t.Setenv("NZ_SITENAME", "lowkick1")
const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
var testFrontendTemplates = []FrontendTemplate{
{Path: "um"},
{Path: "am", IsAdmin: true},
{Path: "um1"},
{Path: "am1", IsAdmin: true},
}
file := newTempConfig(t, testCfg)
c := &Config{}
@@ -134,11 +136,12 @@ func TestReadConfig(t *testing.T) {
Value any
Cond bool
}{
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
{"user_template", c.UserTemplate, c.UserTemplate == "um"},
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
{"site_name", c.SiteName, c.SiteName == "lowkick"},
{"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test1"},
{"jwt_secret_from_env", c.jwtSecretFromEnv, c.jwtSecretFromEnv},
{"user_template", c.UserTemplate, c.UserTemplate == "um1" || c.UserTemplate == "um"},
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am1" || c.AdminTemplate == "am"},
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none" || c.AgentSecretKey == "none1"},
{"site_name", c.SiteName, c.SiteName == "lowkick" || c.SiteName == "lowkick1"},
}
for _, field := range testFields {
@@ -151,6 +154,111 @@ func TestReadConfig(t *testing.T) {
})
}
func TestRotateJWTSecretKeyIfNeeded(t *testing.T) {
tests := []struct {
name string
initialMarker string
currentVersion string
wantRotated bool
wantStoredVersion string
wantSecretChanged bool
wantSavedConfigKey bool
}{
{
name: "empty marker rotates leaked secret",
currentVersion: "v2.0.13",
wantRotated: true,
wantStoredVersion: "v2.0.13",
wantSecretChanged: true,
wantSavedConfigKey: true,
},
{
name: "old marker rotates leaked secret",
initialMarker: "v2.0.12",
currentVersion: "v2.0.14",
wantRotated: true,
wantStoredVersion: "v2.0.14",
wantSecretChanged: true,
wantSavedConfigKey: true,
},
{
name: "threshold marker keeps secret and advances marker",
initialMarker: "v2.0.13",
currentVersion: "v2.0.14",
wantStoredVersion: "v2.0.14",
wantSavedConfigKey: true,
},
{
name: "current marker keeps secret",
initialMarker: "v2.0.14",
currentVersion: "v2.0.14",
wantStoredVersion: "v2.0.14",
},
{
name: "debug version skips rotation and marker update",
currentVersion: "debug",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
file := newTempConfig(t, "")
t.Cleanup(func() { os.Remove(file) })
c := &Config{
JWTSecretKey: "leaked-secret",
JWTSecretKeyLastRotatedVersion: tt.initialMarker,
filePath: file,
}
rotated, err := c.RotateJWTSecretKeyIfNeeded(tt.currentVersion)
if err != nil {
t.Fatalf("rotate jwt secret key failed: %v", err)
}
if rotated != tt.wantRotated {
t.Fatalf("rotated = %v, want %v", rotated, tt.wantRotated)
}
if c.JWTSecretKeyLastRotatedVersion != tt.wantStoredVersion {
t.Fatalf("jwt secret key marker = %q, want %q", c.JWTSecretKeyLastRotatedVersion, tt.wantStoredVersion)
}
secretChanged := c.JWTSecretKey != "leaked-secret"
if secretChanged != tt.wantSecretChanged {
t.Fatalf("secret changed = %v, want %v", secretChanged, tt.wantSecretChanged)
}
saved, err := os.ReadFile(file)
if err != nil {
t.Fatalf("read saved config: %v", err)
}
hasMarker := strings.Contains(string(saved), "jwt_secret_key_last_rotated_version")
if hasMarker != tt.wantSavedConfigKey {
t.Fatalf("saved marker present = %v, want %v, config = %s", hasMarker, tt.wantSavedConfigKey, saved)
}
})
}
}
// Mirrors the upstream single-block declaration so iota lines up exactly:
// ConfigUsePeerIP occupies iota=0 (as a typed string), ConfigCoverAll=1,
// ConfigCoverIgnoreAll=2. Pins persisted `cover` semantics.
const (
originalConfigUsePeerIP = "NZ::Use-Peer-IP"
originalConfigCoverAll = iota
originalConfigCoverIgnoreAll
)
func TestConfigCoverConstantValues(t *testing.T) {
if ConfigUsePeerIP != originalConfigUsePeerIP {
t.Fatalf("ConfigUsePeerIP = %q, want %q", ConfigUsePeerIP, originalConfigUsePeerIP)
}
if ConfigCoverAll != originalConfigCoverAll {
t.Fatalf("ConfigCoverAll = %d, want original value %d", ConfigCoverAll, originalConfigCoverAll)
}
if ConfigCoverIgnoreAll != originalConfigCoverIgnoreAll {
t.Fatalf("ConfigCoverIgnoreAll = %d, want original value %d", ConfigCoverIgnoreAll, originalConfigCoverIgnoreAll)
}
}
func newTempConfig(t *testing.T, cfg string) string {
t.Helper()
+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]")
}
})
}
+19
View File
@@ -0,0 +1,19 @@
package model
import "time"
type JWTSession struct {
KeyID string `gorm:"primaryKey;type:char(64)" json:"key_id"`
UserID uint64 `gorm:"index:idx_jwt_sessions_user_revoked" json:"user_id"`
IP string `gorm:"type:varchar(64)" json:"ip"`
UAHash string `gorm:"type:char(64)" json:"ua_hash"`
TokenVersion uint64 `json:"token_version"`
ExpiresAt time.Time `gorm:"index" json:"expires_at"`
RevokedAt *time.Time `gorm:"index:idx_jwt_sessions_user_revoked" json:"revoked_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastUsedAt time.Time `json:"last_used_at"`
}
func (JWTSession) TableName() string {
return "jwt_sessions"
}
+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")
}
}
+77 -38
View File
@@ -125,7 +125,6 @@ func (n *Notification) setRequestHeader(req *http.Request) error {
func (ns *NotificationServerBundle) Send(message string) error {
n := ns.Notification
if n.Type == NotificationTypeEmail || n.Type == NotificationTypeTelegram {
template := n.RequestBody
if template == "" {
@@ -142,12 +141,8 @@ func (ns *NotificationServerBundle) Send(message string) error {
return nil
}
var client *http.Client
if n.VerifyTLS != nil && *n.VerifyTLS {
client = utils.HttpClient
} else {
client = utils.HttpClientSkipTlsVerify
}
verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS
reqBody, err := ns.reqBody(message)
if err != nil {
@@ -159,7 +154,13 @@ func (ns *NotificationServerBundle) Send(message string) error {
return err
}
req, err := http.NewRequest(reqMethod, ns.reqURL(message), strings.NewReader(reqBody))
reqURL := ns.reqURL(message)
client, err := newNotificationHTTPClient(reqURL, verifyTLS)
if err != nil {
return err
}
req, err := http.NewRequest(reqMethod, reqURL, strings.NewReader(reqBody))
if err != nil {
return err
}
@@ -179,8 +180,7 @@ func (ns *NotificationServerBundle) Send(message string) error {
}()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("%d@%s %s", resp.StatusCode, resp.Status, string(body))
return notificationResponseError(resp)
} else {
_, _ = io.Copy(io.Discard, resp.Body)
}
@@ -188,9 +188,14 @@ func (ns *NotificationServerBundle) Send(message string) error {
return nil
}
func notificationResponseError(resp *http.Response) error {
_, _ = io.CopyN(io.Discard, resp.Body, 4096)
return fmt.Errorf("%d@%s", resp.StatusCode, resp.Status)
}
func newNotificationHTTPClient(rawURL string, verifyTLS bool) (*http.Client, error) {
return utils.NewRestrictedHTTPClient(rawURL, !verifyTLS)
}
// replaceParamInString 替换字符串中的占位符
func (ns *NotificationServerBundle) replaceParamsInString(str string, message string, mod func(string) string) string {
@@ -235,36 +240,41 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
"#SERVER.BILLING_CYCLE#", mod(cycleStr),
)
if ns.Server.State != nil && ns.Server.Host != nil {
runtime := ns.Server.RuntimeSnapshot()
if runtime.State != nil && runtime.Host != nil {
state := runtime.State
host := runtime.Host
replacements = append(replacements,
// Converted metrics
"#SERVER.CPU#", mod(ns.formatUsage(false, ns.Server.State.CPU)),
"#SERVER.MEM#", mod(ns.formatUsage(true, float64(ns.Server.State.MemUsed)/float64(ns.Server.Host.MemTotal))),
"#SERVER.SWAP#", mod(ns.formatUsage(true, float64(ns.Server.State.SwapUsed)/float64(ns.Server.Host.SwapTotal))),
"#SERVER.DISK#", mod(ns.formatUsage(true, float64(ns.Server.State.DiskUsed)/float64(ns.Server.Host.DiskTotal))),
"#SERVER.SPEEDIN#", mod(fmt.Sprintf("%s/s", ns.formatSize(ns.Server.State.NetInSpeed))),
"#SERVER.SPEEDOUT#", mod(fmt.Sprintf("%s/s", ns.formatSize(ns.Server.State.NetOutSpeed))),
"#SERVER.TRANSFERIN#", mod(ns.formatSize(ns.Server.State.NetInTransfer)),
"#SERVER.TRANSFEROUT#", mod(ns.formatSize(ns.Server.State.NetOutTransfer)),
"#SERVER.CPU#", mod(ns.formatUsage(false, state.CPU)),
"#SERVER.MEM#", mod(ns.formatUsage(true, float64(state.MemUsed)/float64(host.MemTotal))),
"#SERVER.SWAP#", mod(ns.formatUsage(true, float64(state.SwapUsed)/float64(host.SwapTotal))),
"#SERVER.DISK#", mod(ns.formatUsage(true, float64(state.DiskUsed)/float64(host.DiskTotal))),
"#SERVER.SPEEDIN#", mod(fmt.Sprintf("%s/s", ns.formatSize(state.NetInSpeed))),
"#SERVER.SPEEDOUT#", mod(fmt.Sprintf("%s/s", ns.formatSize(state.NetOutSpeed))),
"#SERVER.TRANSFERIN#", mod(ns.formatSize(state.NetInTransfer)),
"#SERVER.TRANSFEROUT#", mod(ns.formatSize(state.NetOutTransfer)),
// Raw metrics
"#SERVER.CPUUSED#", mod(fmt.Sprintf("%f", ns.Server.State.CPU)),
"#SERVER.MEMUSED#", mod(fmt.Sprintf("%d", ns.Server.State.MemUsed)),
"#SERVER.SWAPUSED#", mod(fmt.Sprintf("%d", ns.Server.State.SwapUsed)),
"#SERVER.DISKUSED#", mod(fmt.Sprintf("%d", ns.Server.State.DiskUsed)),
"#SERVER.NETINSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetInSpeed)),
"#SERVER.NETOUTSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutSpeed)),
"#SERVER.TRANSFERINRAW#", mod(fmt.Sprintf("%d", ns.Server.State.NetInTransfer)),
"#SERVER.TRANSFEROUTRAW#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutTransfer)),
"#SERVER.UPTIME#", mod(fmt.Sprintf("%d", ns.Server.State.Uptime)),
"#SERVER.MEMTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.MemTotal)),
"#SERVER.SWAPTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.SwapTotal)),
"#SERVER.DISKTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.DiskTotal)),
"#SERVER.LOAD1#", mod(fmt.Sprintf("%f", ns.Server.State.Load1)),
"#SERVER.LOAD5#", mod(fmt.Sprintf("%f", ns.Server.State.Load5)),
"#SERVER.LOAD15#", mod(fmt.Sprintf("%f", ns.Server.State.Load15)),
"#SERVER.TCPCONNCOUNT#", mod(fmt.Sprintf("%d", ns.Server.State.TcpConnCount)),
"#SERVER.UDPCONNCOUNT#", mod(fmt.Sprintf("%d", ns.Server.State.UdpConnCount)),
"#SERVER.CPUUSED#", mod(fmt.Sprintf("%f", state.CPU)),
"#SERVER.MEMUSED#", mod(fmt.Sprintf("%d", state.MemUsed)),
"#SERVER.SWAPUSED#", mod(fmt.Sprintf("%d", state.SwapUsed)),
"#SERVER.DISKUSED#", mod(fmt.Sprintf("%d", state.DiskUsed)),
"#SERVER.NETINSPEED#", mod(fmt.Sprintf("%d", state.NetInSpeed)),
"#SERVER.NETOUTSPEED#", mod(fmt.Sprintf("%d", state.NetOutSpeed)),
"#SERVER.TRANSFERINRAW#", mod(fmt.Sprintf("%d", state.NetInTransfer)),
"#SERVER.TRANSFEROUTRAW#", mod(fmt.Sprintf("%d", state.NetOutTransfer)),
"#SERVER.NETINTRANSFER#", mod(fmt.Sprintf("%d", state.NetInTransfer)),
"#SERVER.NETOUTTRANSFER#", mod(fmt.Sprintf("%d", state.NetOutTransfer)),
"#SERVER.UPTIME#", mod(fmt.Sprintf("%d", state.Uptime)),
"#SERVER.MEMTOTAL#", mod(fmt.Sprintf("%d", host.MemTotal)),
"#SERVER.SWAPTOTAL#", mod(fmt.Sprintf("%d", host.SwapTotal)),
"#SERVER.DISKTOTAL#", mod(fmt.Sprintf("%d", host.DiskTotal)),
"#SERVER.LOAD1#", mod(fmt.Sprintf("%f", state.Load1)),
"#SERVER.LOAD5#", mod(fmt.Sprintf("%f", state.Load5)),
"#SERVER.LOAD15#", mod(fmt.Sprintf("%f", state.Load15)),
"#SERVER.TCPCONNCOUNT#", mod(fmt.Sprintf("%d", state.TcpConnCount)),
"#SERVER.UDPCONNCOUNT#", mod(fmt.Sprintf("%d", state.UdpConnCount)),
)
} else {
replacements = append(replacements,
@@ -284,6 +294,8 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
"#SERVER.NETOUTSPEED#", mod("0"),
"#SERVER.TRANSFERINRAW#", mod("0"),
"#SERVER.TRANSFEROUTRAW#", mod("0"),
"#SERVER.NETINTRANSFER#", mod("0"),
"#SERVER.NETOUTTRANSFER#", mod("0"),
"#SERVER.UPTIME#", mod("0"),
"#SERVER.MEMTOTAL#", mod("0"),
"#SERVER.SWAPTOTAL#", mod("0"),
@@ -319,6 +331,33 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
)
}
replacer := strings.NewReplacer(replacements...)
return replacer.Replace(str)
}
var ipv4, ipv6, validIP string
if ns.Server.GeoIP != nil {
ip := ns.Server.GeoIP.IP
if ip.IPv4Addr != "" && ip.IPv6Addr != "" {
ipv4 = ip.IPv4Addr
ipv6 = ip.IPv6Addr
validIP = ipv4
} else if ip.IPv4Addr != "" {
ipv4 = ip.IPv4Addr
validIP = ipv4
} else {
ipv6 = ip.IPv6Addr
validIP = ipv6
}
}
replacements = append(replacements,
"#SERVER.IP#", mod(validIP),
"#SERVER.IPV4#", mod(ipv4),
"#SERVER.IPV6#", mod(ipv6),
)
}
replacer := strings.NewReplacer(replacements...)
return replacer.Replace(str)
}
+135 -1
View File
@@ -1,10 +1,13 @@
package model
import (
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/nezhahq/nezha/pkg/utils"
)
var (
@@ -77,7 +80,6 @@ func execCase(t *testing.T, item testSt) {
CountryCode: "",
},
LastActive: time.Time{},
TaskStream: nil,
PrevTransferInSnapshot: 0,
PrevTransferOutSnapshot: 0,
}
@@ -234,3 +236,135 @@ func TestNotification(t *testing.T) {
execCase(t, c)
}
}
func TestNotificationResponseErrorDoesNotReflectNonSuccessResponseBody(t *testing.T) {
const internalResponseBody = "internal service says token=secret"
resp := &http.Response{
StatusCode: http.StatusTeapot,
Status: "418 I'm a teapot",
Body: io.NopCloser(strings.NewReader(internalResponseBody)),
}
err := notificationResponseError(resp)
if strings.Contains(err.Error(), internalResponseBody) {
t.Fatalf("expected upstream response body to be hidden from error, got %q", err.Error())
}
}
func TestNotificationSendRejectsLoopbackTarget(t *testing.T) {
verifyTLS := true
notification := &Notification{
URL: "http://127.0.0.1/internal",
RequestMethod: NotificationRequestMethodGET,
VerifyTLS: &verifyTLS,
}
bundle := NotificationServerBundle{
Notification: notification,
Loc: time.Local,
}
err := bundle.Send("probe")
if err == nil {
t.Fatal("expected loopback notification URL to be rejected")
}
if !strings.Contains(err.Error(), "not allowed") {
t.Fatalf("expected not allowed error, got %q", err.Error())
}
}
func TestNotificationTargetRejectsBlockedRanges(t *testing.T) {
cases := []string{
"http://0.0.0.0/",
"http://10.1.2.3/",
"http://100.64.0.1/",
"http://127.0.0.1/",
"http://127.255.255.254/",
"http://169.254.169.254/",
"http://172.16.0.1/",
"http://192.0.0.1/",
"http://192.0.2.1/",
"http://192.168.1.1/",
"http://198.18.0.1/",
"http://198.51.100.1/",
"http://203.0.113.1/",
"http://224.0.0.1/",
"http://240.0.0.1/",
"http://[::]/",
"http://[::1]/",
"http://[::ffff:127.0.0.1]/",
"http://[64:ff9b::1]/",
"http://[100::1]/",
"http://[2001:0:0:0:0:0:0:1]/",
"http://[2001:db8::1]/",
"http://[fc00::1]/",
"http://[fe80::1]/",
"http://[ff00::1]/",
"ftp://example.com/",
"file:///etc/passwd",
"http:///path",
}
for _, rawURL := range cases {
t.Run(rawURL, func(t *testing.T) {
if _, _, err := utils.ResolveAllowedHTTPURL(rawURL); err == nil {
t.Fatalf("expected %s to be rejected", rawURL)
}
})
}
}
func TestNotificationTargetAllowsPublicAddresses(t *testing.T) {
cases := []string{
"http://1.1.1.1/path",
"https://8.8.8.8/",
"https://[2606:4700:4700::1111]/",
}
for _, rawURL := range cases {
t.Run(rawURL, func(t *testing.T) {
parsedURL, _, err := utils.ResolveAllowedHTTPURL(rawURL)
if err != nil {
t.Fatalf("expected %s to be allowed, got %v", rawURL, err)
}
if parsedURL == nil {
t.Fatalf("expected parsed url for %s", rawURL)
}
})
}
}
func TestNotificationHTTPClientInvertsVerifyTLSFlag(t *testing.T) {
// newNotificationHTTPClient takes verifyTLS, utils.NewRestrictedHTTPClient
// takes skipVerifyTLS. The wrapper must invert the boolean; if a future
// refactor drops the negation, TLS verification silently turns off.
// SNI / redirect / IP-pinning are covered by pkg/utils/http_test.go.
cases := []struct {
name string
verifyTLS bool
wantSkipVerifyOn bool
}{
{"verifyTLS_true_means_skipVerify_false", true, false},
{"verifyTLS_false_means_skipVerify_true", false, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
client, err := newNotificationHTTPClient("https://1.1.1.1/webhook", tc.verifyTLS)
if err != nil {
t.Fatalf("expected client construction: %v", err)
}
transport, ok := client.Transport.(*http.Transport)
if !ok {
t.Fatalf("expected *http.Transport, got %T", client.Transport)
}
if transport.TLSClientConfig == nil {
t.Fatalf("expected TLSClientConfig to be set")
}
if got := transport.TLSClientConfig.InsecureSkipVerify; got != tc.wantSkipVerifyOn {
t.Fatalf("verifyTLS=%v: expected InsecureSkipVerify=%v, got %v",
tc.verifyTLS, tc.wantSkipVerifyOn, got)
}
})
}
}
+38 -24
View File
@@ -62,73 +62,87 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
}
var src float64
runtime := server.RuntimeSnapshot()
if runtime.State == nil {
return false
}
state := runtime.State
switch u.Type {
case "cpu":
src = float64(server.State.CPU)
src = float64(state.CPU)
case "gpu_max":
src = slices.Max(server.State.GPU)
src = slices.Max(state.GPU)
case "memory":
src = percentage(server.State.MemUsed, server.Host.MemTotal)
if runtime.Host == nil {
return false
}
src = percentage(state.MemUsed, runtime.Host.MemTotal)
case "swap":
src = percentage(server.State.SwapUsed, server.Host.SwapTotal)
if runtime.Host == nil {
return false
}
src = percentage(state.SwapUsed, runtime.Host.SwapTotal)
case "disk":
src = percentage(server.State.DiskUsed, server.Host.DiskTotal)
if runtime.Host == nil {
return false
}
src = percentage(state.DiskUsed, runtime.Host.DiskTotal)
case "net_in_speed":
src = float64(server.State.NetInSpeed)
src = float64(state.NetInSpeed)
case "net_out_speed":
src = float64(server.State.NetOutSpeed)
src = float64(state.NetOutSpeed)
case "net_all_speed":
src = float64(server.State.NetOutSpeed + server.State.NetOutSpeed)
src = float64(state.NetOutSpeed + state.NetOutSpeed)
case "transfer_in":
src = float64(server.State.NetInTransfer)
src = float64(state.NetInTransfer)
case "transfer_out":
src = float64(server.State.NetOutTransfer)
src = float64(state.NetOutTransfer)
case "transfer_all":
src = float64(server.State.NetOutTransfer + server.State.NetInTransfer)
src = float64(state.NetOutTransfer + state.NetInTransfer)
case "offline":
if server.LastActive.IsZero() {
if runtime.LastActive.IsZero() {
src = 0
} else {
src = float64(server.LastActive.Unix())
src = float64(runtime.LastActive.Unix())
}
case "transfer_in_cycle":
src = float64(utils.SubUintChecked(server.State.NetInTransfer, server.PrevTransferInSnapshot))
src = float64(utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
src += float64(res.N)
}
case "transfer_out_cycle":
src = float64(utils.SubUintChecked(server.State.NetOutTransfer, server.PrevTransferOutSnapshot))
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
src += float64(res.N)
}
case "transfer_all_cycle":
src = float64(utils.SubUintChecked(server.State.NetOutTransfer, server.PrevTransferOutSnapshot) + utils.SubUintChecked(server.State.NetInTransfer, server.PrevTransferInSnapshot))
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot) + utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
src += float64(res.N)
}
case "load1":
src = server.State.Load1
src = state.Load1
case "load5":
src = server.State.Load5
src = state.Load5
case "load15":
src = server.State.Load15
src = state.Load15
case "tcp_conn_count":
src = float64(server.State.TcpConnCount)
src = float64(state.TcpConnCount)
case "udp_conn_count":
src = float64(server.State.UdpConnCount)
src = float64(state.UdpConnCount)
case "process_count":
src = float64(server.State.ProcessCount)
src = float64(state.ProcessCount)
case "temperature_max":
var temp []float64
if server.State.Temperatures != nil {
for _, tempStat := range server.State.Temperatures {
if state.Temperatures != nil {
for _, tempStat := range state.Temperatures {
if tempStat.Temperature != 0 {
temp = append(temp, tempStat.Temperature)
}
+640 -8
View File
@@ -1,10 +1,14 @@
package model
import (
"errors"
"log"
"slices"
"sync"
"sync/atomic"
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"gorm.io/datatypes"
"gorm.io/gorm"
@@ -12,6 +16,8 @@ import (
pb "github.com/nezhahq/nezha/proto"
)
var runtimeHolderInitMu sync.Mutex
type Server struct {
Common
@@ -34,29 +40,466 @@ type Server struct {
GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"`
LastActive time.Time `gorm:"-" json:"last_active,omitempty"`
TaskStream pb.NezhaService_RequestTaskServer `gorm:"-" json:"-"`
ConfigCache chan any `gorm:"-" json:"-"`
// taskStream MUST be accessed only via SetTaskStream / GetTaskStream. Direct
// field access from outside this file races with the gRPC RequestTask
// 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. 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]
runtime atomic.Pointer[serverRuntimeHolder]
ConfigCache chan any `gorm:"-" json:"-"`
PrevTransferInSnapshot uint64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量
PrevTransferOutSnapshot uint64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量
}
// taskStreamHolder wraps the interface so atomic.Pointer (which requires a
// concrete pointed-to type) can publish it atomically. The previous bare
// 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
sendMu sync.Mutex
}
type serverRuntimeHolder struct {
mu sync.Mutex
canonical *Server
stream pb.NezhaService_ReportSystemStateServer
generation uint64
state *HostState
host *Host
lastActive time.Time
prevIn uint64
prevOut uint64
}
type StateStreamLease struct {
holder *serverRuntimeHolder
generation uint64
}
func (lease StateStreamLease) Generation() uint64 {
return lease.generation
}
type RuntimeHandle struct {
holder *serverRuntimeHolder
}
type HostReportResult struct {
ServerID uint64
UUID string
Applied bool
Initial bool
Equal bool
Stale bool
Restart bool
Transfer Transfer
}
func (s *Server) RuntimeHandle() RuntimeHandle {
runtimeHolderInitMu.Lock()
holder := s.runtime.Load()
if holder == nil {
holder = &serverRuntimeHolder{canonical: s, state: cloneHostState(s.State), host: cloneHost(s.Host), lastActive: s.LastActive, prevIn: s.PrevTransferInSnapshot, prevOut: s.PrevTransferOutSnapshot}
s.runtime.Store(holder)
}
runtimeHolderInitMu.Unlock()
return RuntimeHandle{holder: holder}
}
func (handle RuntimeHandle) ApplyHostReport(host *Host, createdAt time.Time, persist func(Transfer) error) (HostReportResult, error) {
if handle.holder == nil || host == nil {
return HostReportResult{}, errors.New("invalid runtime handle")
}
holder := handle.holder
holder.mu.Lock()
defer holder.mu.Unlock()
canonical := holder.canonical
if canonical == nil {
return HostReportResult{}, errors.New("runtime handle has no canonical server")
}
result := HostReportResult{ServerID: canonical.ID, UUID: canonical.UUID}
if holder.host == nil {
holder.host = cloneHost(host)
canonical.Host = cloneHost(host)
result.Applied = true
result.Initial = true
return result, nil
}
if host.BootTime < holder.host.BootTime {
result.Stale = true
return result, nil
}
if host.BootTime == holder.host.BootTime {
holder.host = cloneHost(host)
canonical.Host = cloneHost(host)
result.Applied = true
result.Equal = true
return result, nil
}
result.Restart = true
if holder.state != nil {
result.Transfer = Transfer{Common: Common{CreatedAt: createdAt}, ServerID: canonical.ID, In: holder.state.NetInTransfer - min(holder.state.NetInTransfer, holder.prevIn), Out: holder.state.NetOutTransfer - min(holder.state.NetOutTransfer, holder.prevOut)}
}
if persist != nil {
if err := persist(result.Transfer); err != nil {
return HostReportResult{}, err
}
}
holder.host = cloneHost(host)
holder.state = &HostState{}
holder.lastActive = time.Time{}
holder.prevIn, holder.prevOut = 0, 0
canonical.Host = cloneHost(host)
canonical.State = &HostState{}
canonical.LastActive = time.Time{}
canonical.PrevTransferInSnapshot = 0
canonical.PrevTransferOutSnapshot = 0
result.Applied = true
return result, nil
}
func (lease StateStreamLease) UpdateState(state *HostState, lastActive time.Time) bool {
return lease.UpdateStateWithSideEffect(state, lastActive, nil)
}
func (lease StateStreamLease) UpdateStateWithSideEffect(state *HostState, lastActive time.Time, sideEffect func() error) bool {
return lease.updateState(nil, state, lastActive, sideEffect)
}
func (lease StateStreamLease) updateState(receiver *Server, state *HostState, lastActive time.Time, sideEffect func() error) bool {
if lease.holder == nil {
return false
}
lease.holder.mu.Lock()
defer lease.holder.mu.Unlock()
if lease.holder.generation != lease.generation || lease.holder.stream == nil || lease.holder.canonical == nil || (receiver != nil && lease.holder.canonical != receiver) {
return false
}
canonical := lease.holder.canonical
canonical.State = cloneHostState(state)
canonical.LastActive = lastActive
lease.holder.state = cloneHostState(state)
lease.holder.lastActive = lastActive
if lease.holder.prevIn == 0 || lease.holder.prevOut == 0 {
lease.holder.prevIn = state.NetInTransfer
lease.holder.prevOut = state.NetOutTransfer
}
canonical.PrevTransferInSnapshot = lease.holder.prevIn
canonical.PrevTransferOutSnapshot = lease.holder.prevOut
if sideEffect != nil {
if err := sideEffect(); err != nil {
return false
}
}
return true
}
func (lease StateStreamLease) Clear() bool {
return lease.clear(nil)
}
func (lease StateStreamLease) clear(receiver *Server) bool {
if lease.holder == nil {
return false
}
lease.holder.mu.Lock()
defer lease.holder.mu.Unlock()
if lease.holder.generation != lease.generation || lease.holder.stream == nil || lease.holder.canonical == nil || (receiver != nil && lease.holder.canonical != receiver) {
return false
}
lease.holder.stream = nil
lease.holder.lastActive = time.Time{}
lease.holder.canonical.LastActive = time.Time{}
return true
}
// SetTaskStream publishes the agent's RequestTask stream so other goroutines
// can deliver tasks to the agent. Pass nil to detach (e.g. on disconnect).
func (s *Server) SetTaskStream(stream pb.NezhaService_RequestTaskServer) {
if stream == nil {
s.taskStream.Store(nil)
return
}
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.
func (s *Server) ClearTaskStreamIfCurrent(stream pb.NezhaService_RequestTaskServer) bool {
if stream == nil {
return false
}
for {
h := s.taskStream.Load()
if h == nil || h.s != stream {
return false
}
if s.taskStream.CompareAndSwap(h, nil) {
return true
}
}
}
// GetTaskStream returns the currently-published stream, or nil if the agent
// is offline. Callers MUST capture the return into a local variable before
// using it — re-reading via GetTaskStream() across a Send call reopens the
// race we're trying to close.
func (s *Server) GetTaskStream() pb.NezhaService_RequestTaskServer {
h := s.taskStream.Load()
if h == nil {
return nil
}
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)
}
// AttachStateStream returns the ownership generation used to serialize state
// writes with reconnect and disconnect cleanup.
func (s *Server) AttachStateStream(stream pb.NezhaService_ReportSystemStateServer) StateStreamLease {
if stream == nil {
return StateStreamLease{}
}
runtimeHolderInitMu.Lock()
defer runtimeHolderInitMu.Unlock()
holder := s.runtime.Load()
if holder == nil {
candidate := &serverRuntimeHolder{canonical: s, state: cloneHostState(s.State), host: cloneHost(s.Host), lastActive: s.LastActive, prevIn: s.PrevTransferInSnapshot, prevOut: s.PrevTransferOutSnapshot}
if s.runtime.CompareAndSwap(nil, candidate) {
holder = candidate
} else {
holder = s.runtime.Load()
}
}
holder.mu.Lock()
defer holder.mu.Unlock()
holder.generation++
holder.stream = stream
return StateStreamLease{holder: holder, generation: holder.generation}
}
func (s *Server) UpdateStateIfCurrent(lease StateStreamLease, state *HostState, lastActive time.Time) bool {
return s.UpdateStateIfCurrentWithSideEffect(lease, state, lastActive, nil)
}
func (s *Server) UpdateStateIfCurrentWithSideEffect(lease StateStreamLease, state *HostState, lastActive time.Time, sideEffect func() error) bool {
return lease.updateState(s, state, lastActive, sideEffect)
}
func (s *Server) ClearStateStreamIfCurrent(lease StateStreamLease) bool {
return lease.clear(s)
}
// RuntimeSnapshot is a deep copy of the mutable runtime state.
type RuntimeSnapshot struct {
State *HostState
Host *Host
LastActive time.Time
PrevTransferInSnapshot uint64
PrevTransferOutSnapshot uint64
}
func (s *Server) RuntimeSnapshot() RuntimeSnapshot {
runtimeHolderInitMu.Lock()
holder := s.runtime.Load()
if holder == nil {
candidate := &serverRuntimeHolder{canonical: s, state: cloneHostState(s.State), lastActive: s.LastActive, prevIn: s.PrevTransferInSnapshot, prevOut: s.PrevTransferOutSnapshot}
if s.runtime.CompareAndSwap(nil, candidate) {
holder = candidate
} else {
holder = s.runtime.Load()
}
}
runtimeHolderInitMu.Unlock()
holder.mu.Lock()
defer holder.mu.Unlock()
if holder.canonical == s {
if holder.state == nil {
holder.state = cloneHostState(s.State)
}
if holder.host == nil {
holder.host = cloneHost(s.Host)
}
}
return RuntimeSnapshot{State: cloneHostState(holder.state), Host: cloneHost(holder.host), LastActive: holder.lastActive, PrevTransferInSnapshot: holder.prevIn, PrevTransferOutSnapshot: holder.prevOut}
}
func (s *Server) SetTransferSnapshots(inbound, outbound uint64) bool {
runtimeHolderInitMu.Lock()
holder := s.runtime.Load()
if holder == nil {
holder = &serverRuntimeHolder{canonical: s, state: cloneHostState(s.State), lastActive: s.LastActive}
s.runtime.Store(holder)
}
runtimeHolderInitMu.Unlock()
holder.mu.Lock()
if holder.canonical != s {
holder.mu.Unlock()
return false
}
holder.prevIn = inbound
holder.prevOut = outbound
if holder.canonical != nil {
holder.canonical.PrevTransferInSnapshot = inbound
holder.canonical.PrevTransferOutSnapshot = outbound
}
holder.mu.Unlock()
return true
}
func (s *Server) TransferSnapshotDelta() (inbound, outbound, snapshotIn, snapshotOut uint64) {
snapshot := s.RuntimeSnapshot()
if snapshot.State == nil {
return 0, 0, snapshot.PrevTransferInSnapshot, snapshot.PrevTransferOutSnapshot
}
return snapshot.State.NetInTransfer, snapshot.State.NetOutTransfer, snapshot.PrevTransferInSnapshot, snapshot.PrevTransferOutSnapshot
}
func (s *Server) TransferDeltaAndAdvance() (inbound, outbound uint64, deltaIn, deltaOut uint64) {
runtimeHolderInitMu.Lock()
holder := s.runtime.Load()
if holder == nil {
holder = &serverRuntimeHolder{canonical: s, state: cloneHostState(s.State), host: cloneHost(s.Host), lastActive: s.LastActive, prevIn: s.PrevTransferInSnapshot, prevOut: s.PrevTransferOutSnapshot}
s.runtime.Store(holder)
}
runtimeHolderInitMu.Unlock()
holder.mu.Lock()
defer holder.mu.Unlock()
if holder.canonical != s || holder.state == nil {
return 0, 0, 0, 0
}
inbound, outbound = holder.state.NetInTransfer, holder.state.NetOutTransfer
deltaIn = inbound - min(inbound, holder.prevIn)
deltaOut = outbound - min(outbound, holder.prevOut)
holder.prevIn, holder.prevOut = inbound, outbound
if holder.canonical != nil {
holder.canonical.PrevTransferInSnapshot = inbound
holder.canonical.PrevTransferOutSnapshot = outbound
}
return
}
func cloneHostState(state *HostState) *HostState {
if state == nil {
return nil
}
clone := *state
clone.GPU = slices.Clone(state.GPU)
clone.Temperatures = slices.Clone(state.Temperatures)
return &clone
}
func cloneHost(host *Host) *Host {
if host == nil {
return nil
}
clone := *host
clone.CPU = slices.Clone(host.CPU)
clone.GPU = slices.Clone(host.GPU)
return &clone
}
func (s *Server) SetHost(host *Host) bool {
runtimeHolderInitMu.Lock()
holder := s.runtime.Load()
if holder == nil {
holder = &serverRuntimeHolder{canonical: s, state: cloneHostState(s.State), lastActive: s.LastActive}
s.runtime.Store(holder)
}
runtimeHolderInitMu.Unlock()
holder.mu.Lock()
if holder.canonical != s {
holder.mu.Unlock()
return false
}
holder.host = cloneHost(host)
if holder.canonical != nil {
holder.canonical.Host = cloneHost(host)
}
holder.mu.Unlock()
return true
}
// 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{}
s.GeoIP = &GeoIP{}
s.ConfigCache = make(chan any, 1)
s.runtime.Store(&serverRuntimeHolder{canonical: s, state: cloneHostState(s.State), host: cloneHost(s.Host)})
}
func (s *Server) CopyFromRunningServer(old *Server) {
s.Host = old.Host
s.State = old.State
runtimeHolderInitMu.Lock()
defer runtimeHolderInitMu.Unlock()
s.GeoIP = old.GeoIP
s.LastActive = old.LastActive
s.TaskStream = old.TaskStream
// 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())
holder := old.runtime.Load()
if holder == nil {
holder = &serverRuntimeHolder{canonical: old, state: cloneHostState(old.State), host: cloneHost(old.Host), lastActive: old.LastActive, prevIn: old.PrevTransferInSnapshot, prevOut: old.PrevTransferOutSnapshot}
old.runtime.CompareAndSwap(nil, holder)
holder = old.runtime.Load()
}
holder.mu.Lock()
holder.canonical = s
s.runtime.Store(holder)
s.State = cloneHostState(holder.state)
s.Host = cloneHost(holder.host)
s.LastActive = holder.lastActive
s.PrevTransferInSnapshot = holder.prevIn
s.PrevTransferOutSnapshot = holder.prevOut
holder.mu.Unlock()
s.ConfigCache = old.ConfigCache
s.PrevTransferInSnapshot = old.PrevTransferInSnapshot
s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot
}
func (s *Server) AfterFind(tx *gorm.DB) error {
@@ -75,6 +518,195 @@ func (s *Server) AfterFind(tx *gorm.DB) error {
return nil
}
// ServerOwnerInfo carries the user-facing identity for Server.UserID. It is
// returned by the lookup function installed by the singleton layer; model
// must not import singleton (cycle), so the dependency flows through a
// package-level function variable instead.
type ServerOwnerInfo struct {
ID uint64 `json:"id"`
Username string `json:"username,omitempty"`
}
// ServerOwnerLookup is installed by singleton at startup to resolve a
// Server.UserID into a display-ready owner record. Returns ok=false when
// the uid does not map to a known user; the caller renders that as an
// "unknown user" placeholder so deleted-user rows stay debuggable. Left nil
// 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 {
*serverJSON
Owner *ServerOwnerInfo `json:"owner,omitempty"`
}
// MarshalJSON projects Server.UserID into a structured owner field on the
// wire. Server.UserID itself stays `json:"-"` (set on Common) so callers
// that do not need owner info pay nothing and members do not accidentally
// receive raw uid integers. The lookup function is consulted only when
// installed; if absent we still emit a minimal {id} record so clients can
// at least distinguish ownership, except for uid=0 which is the legacy
// global-secret pseudo-owner and is best surfaced as such by the caller's
// translation table on the frontend.
func (s *Server) MarshalJSON() ([]byte, error) {
runtime := s.RuntimeSnapshot()
copy := s.RuntimeCopy(runtime)
owner := &ServerOwnerInfo{ID: s.GetUserID()}
if ServerOwnerLookup != nil {
if info, ok := ServerOwnerLookup(owner.ID); ok {
owner.Username = info.Username
}
}
return json.Marshal(serverWithOwner{
serverJSON: (*serverJSON)(copy),
Owner: owner,
})
}
func (s *Server) RuntimeCopy(runtime RuntimeSnapshot) *Server {
return &Server{
Common: Common{
ID: s.ID,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
UserID: s.GetUserID(),
},
Name: s.Name,
UUID: s.UUID,
Note: s.Note,
PublicNote: s.PublicNote,
DisplayIndex: s.DisplayIndex,
HideForGuest: s.HideForGuest,
EnableDDNS: s.EnableDDNS,
DDNSProfilesRaw: s.DDNSProfilesRaw,
OverrideDDNSDomainsRaw: s.OverrideDDNSDomainsRaw,
DDNSProfiles: slices.Clone(s.DDNSProfiles),
OverrideDDNSDomains: s.OverrideDDNSDomains,
Host: runtime.Host,
State: runtime.State,
GeoIP: s.GeoIP,
LastActive: runtime.LastActive,
ConfigCache: s.ConfigCache,
PrevTransferInSnapshot: runtime.PrevTransferInSnapshot,
PrevTransferOutSnapshot: runtime.PrevTransferOutSnapshot,
}
}
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
+6 -2
View File
@@ -21,8 +21,12 @@ type StreamServerData struct {
}
type ServerForm struct {
Name string `json:"name,omitempty"`
Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注
Name string `json:"name,omitempty"`
Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注
// PublicNote is opaque public metadata consumed by independently maintained
// user themes. The Dashboard stores/transports it but never renders it as
// HTML or navigates URL-like fields. Themes must validate schemes before
// using nested values such as customData.orderLink in href/window.open.
PublicNote string `json:"public_note,omitempty" validate:"optional"` // 公开备注
DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"` // 对游客隐藏
+129
View File
@@ -0,0 +1,129 @@
package model
import (
"encoding/json"
"testing"
)
// Server.MarshalJSON projects Server.UserID into a public owner field while
// keeping the raw UserID json-hidden. The lookup function is package-level
// and shared across tests; each subtest installs its own stub and restores
// the original to avoid leaking state.
func TestServerMarshalJSONOwnerProjection(t *testing.T) {
original := ServerOwnerLookup
t.Cleanup(func() { ServerOwnerLookup = original })
tests := []struct {
name string
uid uint64
lookup func(uid uint64) (ServerOwnerInfo, bool)
wantID uint64
wantHasName bool
wantName string
}{
{
// uid=0 is the legacy global agent secret pseudo-owner. The
// lookup deliberately returns ok=false so the frontend can
// render it as "Global Agent" instead of a real username.
name: "uid_zero_has_no_username",
uid: 0,
lookup: func(uint64) (ServerOwnerInfo, bool) {
return ServerOwnerInfo{}, false
},
wantID: 0,
wantHasName: false,
},
{
// Known user → username flows through to the wire so the
// admin frontend can show it without a separate /user fetch
// (which members cannot call anyway).
name: "known_user_has_username",
uid: 42,
lookup: func(uid uint64) (ServerOwnerInfo, bool) {
return ServerOwnerInfo{ID: uid, Username: "alice"}, true
},
wantID: 42,
wantHasName: true,
wantName: "alice",
},
{
// Deleted user → lookup returns ok=false. The wire still
// carries owner.id so the frontend can render an "Unknown
// user (#id)" placeholder; otherwise the row would silently
// appear ownerless and ops would lose the audit trail.
name: "deleted_user_keeps_id_without_username",
uid: 999,
lookup: func(uint64) (ServerOwnerInfo, bool) {
return ServerOwnerInfo{}, false
},
wantID: 999,
wantHasName: false,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ServerOwnerLookup = tc.lookup
s := &Server{Common: Common{ID: 7, UserID: tc.uid}, Name: "srv"}
raw, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got struct {
Owner *ServerOwnerInfo `json:"owner"`
// Owner must never appear as the raw uid via Common.UserID;
// the Common.UserID json tag is "-" and a regression that
// flips it to "user_id" would expose internal owner ids
// to the wire bypassing the lookup-controlled rendering.
UserID *uint64 `json:"user_id,omitempty"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.UserID != nil {
t.Fatalf("Common.UserID must not appear on the wire as user_id, got %d", *got.UserID)
}
if got.Owner == nil {
t.Fatalf("owner field must always be present, raw=%s", raw)
}
if got.Owner.ID != tc.wantID {
t.Fatalf("owner.id=%d, want %d", got.Owner.ID, tc.wantID)
}
if tc.wantHasName {
if got.Owner.Username != tc.wantName {
t.Fatalf("owner.username=%q, want %q", got.Owner.Username, tc.wantName)
}
} else if got.Owner.Username != "" {
t.Fatalf("owner.username must be omitted for uid=%d, got %q", tc.uid, got.Owner.Username)
}
})
}
}
// When no lookup is installed (tests / headless tools), MarshalJSON must
// still emit a minimal owner record so consumers do not crash on missing
// fields. Without this guard a future refactor could silently drop the
// owner key entirely whenever the hook is nil.
func TestServerMarshalJSONEmitsOwnerWithoutLookup(t *testing.T) {
original := ServerOwnerLookup
t.Cleanup(func() { ServerOwnerLookup = original })
ServerOwnerLookup = nil
raw, err := json.Marshal(&Server{Common: Common{ID: 1, UserID: 17}, Name: "srv"})
if err != nil {
t.Fatalf("marshal: %v", err)
}
var got struct {
Owner *ServerOwnerInfo `json:"owner"`
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.Owner == nil || got.Owner.ID != 17 || got.Owner.Username != "" {
t.Fatalf("expected bare owner record {id:17}, got %+v", got.Owner)
}
}
+86
View File
@@ -0,0 +1,86 @@
package model
import (
"net/http/httptest"
"sync"
"testing"
"github.com/gin-gonic/gin"
)
// Server.UserID 在 server-transfer rotation 流程里会被 ServerTransfer 的
// Register/revertTransition 改写以反映新所有者,同时 authorizeAgentForUUID
// 在每次 agent RPC 里读取它。原实现两处都是裸字段访问,race detector 会
// 报告 data race;这是 review 评分 75 的真实问题。
//
// 修复后所有并发读写都走 SetUserID/GetUserID 的 atomic 包装,本测试在
// `go test -race` 下应该完全跑干净。
func TestServerUserIDConcurrentAccessIsRaceFree(t *testing.T) {
s := &Server{}
const (
writers = 4
readers = 8
rounds = 500
)
var wg sync.WaitGroup
wg.Add(writers + readers)
for i := 0; i < writers; i++ {
uid := uint64(i + 1)
go func() {
defer wg.Done()
for j := 0; j < rounds; j++ {
s.SetUserID(uid)
}
}()
}
for i := 0; i < readers; i++ {
go func() {
defer wg.Done()
for j := 0; j < rounds; j++ {
_ = s.GetUserID()
}
}()
}
wg.Wait()
}
// Common.HasPermission 是 server-transfer 旋转下与 SetUserID 并发的主要读者
// 之一:dashboard 各 controller 的 listHandler post-filter 在 transfer 窗口
// 内不断对同一 *Server 调用 HasPermission,而 Register/revertTransition 同
// 时通过 SetUserID 改写所属用户。原实现的 `user.ID == c.UserID` 是裸读,会
// 与 atomic.StoreUint64 形成 data racego test -race 必爆)。修复后改成走
// GetUserID() 走 atomic 协议。这个测试就是用来在 -race 下钉死该不变量的。
func TestCommonHasPermissionConcurrentWithSetUserIDIsRaceFree(t *testing.T) {
s := &Server{Common: Common{ID: 1}}
const (
writers = 4
readers = 8
rounds = 500
)
var wg sync.WaitGroup
wg.Add(writers + readers)
for i := 0; i < writers; i++ {
uid := uint64(i + 1)
go func() {
defer wg.Done()
for j := 0; j < rounds; j++ {
s.SetUserID(uid)
}
}()
}
for i := 0; i < readers; i++ {
go func() {
defer wg.Done()
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set(CtxKeyAuthorizedUser, &User{Common: Common{ID: 2}, Role: RoleMember})
for j := 0; j < rounds; j++ {
_ = s.HasPermission(ctx)
}
}()
}
wg.Wait()
}
+369
View File
@@ -0,0 +1,369 @@
package model
import (
"context"
"sync"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/metadata"
pb "github.com/nezhahq/nezha/proto"
)
type runtimeOwnershipStream struct{}
func (runtimeOwnershipStream) Send(*pb.Receipt) error { return nil }
func (runtimeOwnershipStream) Recv() (*pb.State, error) { return nil, context.Canceled }
func (runtimeOwnershipStream) SetHeader(metadata.MD) error { return nil }
func (runtimeOwnershipStream) SendHeader(metadata.MD) error { return nil }
func (runtimeOwnershipStream) SetTrailer(metadata.MD) {}
func (runtimeOwnershipStream) Context() context.Context { return context.Background() }
func (runtimeOwnershipStream) SendMsg(any) error { return nil }
func (runtimeOwnershipStream) RecvMsg(any) error { return nil }
func TestServerRuntimeOwnership_replacementAdoptsHolderBeforeFirstAttach(t *testing.T) {
old := &Server{State: &HostState{Uptime: 1}, Host: &Host{BootTime: 10}}
newServer := &Server{}
var lease StateStreamLease
started := make(chan struct{})
var waitGroup sync.WaitGroup
waitGroup.Add(1)
go func() {
defer waitGroup.Done()
close(started)
lease = old.AttachStateStream(runtimeOwnershipStream{})
}()
<-started
newServer.CopyFromRunningServer(old)
waitGroup.Wait()
require.True(t, newServer.UpdateStateIfCurrent(lease, &HostState{Uptime: 2}, time.Unix(2, 0)))
snapshot := newServer.RuntimeSnapshot()
require.Equal(t, uint64(2), snapshot.State.Uptime)
require.Equal(t, time.Unix(2, 0), snapshot.LastActive)
require.False(t, old.ClearStateStreamIfCurrent(lease))
}
func TestServerRuntimeOwnership_oldLeaseMutatesCanonicalAfterReplacement(t *testing.T) {
old := &Server{}
InitServer(old)
lease := old.AttachStateStream(runtimeOwnershipStream{})
newServer := &Server{}
newServer.CopyFromRunningServer(old)
require.True(t, newServer.UpdateStateIfCurrent(lease, &HostState{Uptime: 7}, time.Unix(7, 0)))
snapshot := newServer.RuntimeSnapshot()
require.Equal(t, uint64(7), snapshot.State.Uptime)
require.Equal(t, time.Unix(7, 0), snapshot.LastActive)
require.False(t, old.ClearStateStreamIfCurrent(lease))
require.True(t, newServer.ClearStateStreamIfCurrent(lease))
require.True(t, newServer.RuntimeSnapshot().LastActive.IsZero())
}
func TestServerRuntimeOwnership_leaseMutatesCanonicalWithoutReceiver(t *testing.T) {
// Given
old := &Server{}
InitServer(old)
lease := old.AttachStateStream(runtimeOwnershipStream{})
canonical := &Server{}
canonical.CopyFromRunningServer(old)
// When
accepted := lease.UpdateState(&HostState{Uptime: 19}, time.Unix(19, 0))
// Then
require.True(t, accepted)
require.Equal(t, uint64(19), canonical.RuntimeSnapshot().State.Uptime)
require.Equal(t, time.Unix(19, 0), canonical.RuntimeSnapshot().LastActive)
}
func TestServerRuntimeOwnership_oldReceiverMutatorsCannotChangeCanonical(t *testing.T) {
// Given
old := &Server{}
InitServer(old)
lease := old.AttachStateStream(runtimeOwnershipStream{})
canonical := &Server{}
canonical.CopyFromRunningServer(old)
// When
hostChanged := old.SetHost(&Host{Version: "stale"})
snapshotChanged := old.SetTransferSnapshots(91, 92)
inbound, outbound, deltaIn, deltaOut := old.TransferDeltaAndAdvance()
// Then
require.False(t, hostChanged)
require.False(t, snapshotChanged)
require.Equal(t, uint64(0), inbound)
require.Equal(t, uint64(0), outbound)
require.Equal(t, uint64(0), deltaIn)
require.Equal(t, uint64(0), deltaOut)
require.Empty(t, canonical.RuntimeSnapshot().Host.Version)
require.Equal(t, uint64(0), canonical.RuntimeSnapshot().PrevTransferInSnapshot)
require.Equal(t, uint64(0), canonical.RuntimeSnapshot().PrevTransferOutSnapshot)
require.True(t, lease.UpdateState(&HostState{NetInTransfer: 10, NetOutTransfer: 20}, time.Unix(20, 0)))
}
func TestServerRuntimeOwnership_copyFallbackPreservesHost(t *testing.T) {
// Given
old := &Server{Host: &Host{Version: "fallback"}, State: &HostState{Uptime: 4}, LastActive: time.Unix(4, 0), PrevTransferInSnapshot: 5, PrevTransferOutSnapshot: 6}
canonical := &Server{}
// When
canonical.CopyFromRunningServer(old)
// Then
snapshot := canonical.RuntimeSnapshot()
require.Equal(t, "fallback", snapshot.Host.Version)
require.Equal(t, uint64(4), snapshot.State.Uptime)
require.Equal(t, time.Unix(4, 0), snapshot.LastActive)
require.Equal(t, uint64(5), snapshot.PrevTransferInSnapshot)
require.Equal(t, uint64(6), snapshot.PrevTransferOutSnapshot)
}
func TestServerRuntimeSnapshot_isSafeDuringStateUpdates(t *testing.T) {
server := &Server{}
InitServer(server)
lease := server.AttachStateStream(runtimeOwnershipStream{})
var waitGroup sync.WaitGroup
waitGroup.Add(2)
go func() {
defer waitGroup.Done()
for index := uint64(1); index <= 500; index++ {
server.UpdateStateIfCurrent(lease, &HostState{Uptime: index, GPU: []float64{float64(index)}}, time.Unix(int64(index), 0))
}
}()
go func() {
defer waitGroup.Done()
for index := 0; index < 500; index++ {
snapshot := server.RuntimeSnapshot()
require.NotNil(t, snapshot.State)
if snapshot.State.Uptime > 0 {
require.Len(t, snapshot.State.GPU, 1)
}
}
}()
waitGroup.Wait()
}
func TestServerRuntimeOwnership_restartHostReportUsesCurrentCanonicalOnce(t *testing.T) {
// Given
old := &Server{}
InitServer(old)
lease := old.AttachStateStream(runtimeOwnershipStream{})
require.True(t, lease.UpdateState(&HostState{NetInTransfer: 140, NetOutTransfer: 90}, time.Unix(10, 0)))
require.True(t, old.SetTransferSnapshots(100, 70))
middle := &Server{}
middle.CopyFromRunningServer(old)
current := &Server{}
current.CopyFromRunningServer(middle)
// When
result, err := old.RuntimeHandle().ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), nil)
// Then
require.NoError(t, err)
require.True(t, result.Applied)
require.True(t, result.Restart)
require.Equal(t, current.ID, result.ServerID)
require.Equal(t, uint64(40), result.Transfer.In)
require.Equal(t, uint64(20), result.Transfer.Out)
require.Equal(t, uint64(0), current.RuntimeSnapshot().PrevTransferInSnapshot)
require.Equal(t, uint64(0), current.RuntimeSnapshot().PrevTransferOutSnapshot)
require.Equal(t, uint64(20), current.RuntimeSnapshot().Host.BootTime)
secondResult, secondErr := old.RuntimeHandle().ApplyHostReport(&Host{BootTime: 20}, time.Unix(21, 0), nil)
require.NoError(t, secondErr)
require.True(t, secondResult.Applied)
require.True(t, secondResult.Equal)
require.Zero(t, secondResult.Transfer)
}
func TestServerRuntimeOwnership_hostReportPersistenceFailurePreservesRuntime(t *testing.T) {
// Given
old := &Server{}
InitServer(old)
lease := old.AttachStateStream(runtimeOwnershipStream{})
require.True(t, lease.UpdateState(&HostState{NetInTransfer: 140, NetOutTransfer: 90}, time.Unix(10, 0)))
require.True(t, old.SetTransferSnapshots(100, 70))
current := &Server{}
current.CopyFromRunningServer(old)
handle := old.RuntimeHandle()
before := current.RuntimeSnapshot()
// When
_, err := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), func(Transfer) error {
return context.Canceled
})
// Then
require.ErrorIs(t, err, context.Canceled)
after := current.RuntimeSnapshot()
require.Equal(t, before.Host, after.Host)
require.Equal(t, before.State, after.State)
require.Equal(t, before.LastActive, after.LastActive)
require.Equal(t, before.PrevTransferInSnapshot, after.PrevTransferInSnapshot)
require.Equal(t, before.PrevTransferOutSnapshot, after.PrevTransferOutSnapshot)
}
func TestServerRuntimeOwnership_hostReportRetryPersistsExactlyOnce(t *testing.T) {
// Given
server := &Server{Common: Common{ID: 41}, UUID: "server-41"}
InitServer(server)
lease := server.AttachStateStream(runtimeOwnershipStream{})
require.True(t, lease.UpdateState(&HostState{NetInTransfer: 20, NetOutTransfer: 30}, time.Unix(10, 0)))
require.True(t, server.SetTransferSnapshots(5, 10))
callbackCalls := 0
callback := func(transfer Transfer) error {
callbackCalls++
if callbackCalls == 1 {
return context.Canceled
}
return nil
}
handle := server.RuntimeHandle()
// When
first, firstErr := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), callback)
second, secondErr := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), callback)
third, thirdErr := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(21, 0), callback)
// Then
require.ErrorIs(t, firstErr, context.Canceled)
require.NoError(t, secondErr)
require.NoError(t, thirdErr)
require.Equal(t, 2, callbackCalls)
require.Equal(t, uint64(15), second.Transfer.In)
require.Equal(t, uint64(20), second.Transfer.Out)
require.True(t, third.Equal)
require.Zero(t, third.Transfer)
_ = first
}
func TestServerRuntimeOwnership_hostReportClassifiesLowerAndEqualWithoutRestart(t *testing.T) {
// Given
server := &Server{Common: Common{ID: 42}, UUID: "server-42"}
InitServer(server)
require.True(t, server.SetHost(&Host{BootTime: 20, Version: "old"}))
lease := server.AttachStateStream(runtimeOwnershipStream{})
require.True(t, lease.UpdateState(&HostState{Uptime: 7, NetInTransfer: 30}, time.Unix(7, 0)))
require.True(t, server.SetTransferSnapshots(12, 0))
persistCalls := 0
persist := func(Transfer) error { persistCalls++; return nil }
handle := server.RuntimeHandle()
// When
lower, lowerErr := handle.ApplyHostReport(&Host{BootTime: 19, Version: "stale"}, time.Unix(8, 0), persist)
equal, equalErr := handle.ApplyHostReport(&Host{BootTime: 20, Version: "new"}, time.Unix(9, 0), persist)
// Then
require.NoError(t, lowerErr)
require.True(t, lower.Stale)
require.NoError(t, equalErr)
require.True(t, equal.Equal)
require.Zero(t, persistCalls)
snapshot := server.RuntimeSnapshot()
require.Equal(t, "new", snapshot.Host.Version)
require.Equal(t, uint64(7), snapshot.State.Uptime)
require.Equal(t, time.Unix(7, 0), snapshot.LastActive)
require.Equal(t, uint64(12), snapshot.PrevTransferInSnapshot)
}
func TestServerRuntimeOwnership_hostReportReturnsLatestCanonicalIdentity(t *testing.T) {
// Given
old := &Server{Common: Common{ID: 11}, UUID: "old"}
InitServer(old)
middle := &Server{Common: Common{ID: 22}, UUID: "middle"}
middle.CopyFromRunningServer(old)
current := &Server{Common: Common{ID: 33}, UUID: "current"}
current.CopyFromRunningServer(middle)
// When
result, err := old.RuntimeHandle().ApplyHostReport(&Host{BootTime: 1}, time.Unix(1, 0), nil)
// Then
require.NoError(t, err)
require.True(t, result.Applied)
require.Equal(t, current.ID, result.ServerID)
require.Equal(t, current.UUID, result.UUID)
}
func TestServerRuntimeOwnership_transferAndRestartDoNotDuplicateWhenTransferRunsFirst(t *testing.T) {
// Given
server := &Server{Common: Common{ID: 51}, UUID: "server-51"}
InitServer(server)
lease := server.AttachStateStream(runtimeOwnershipStream{})
require.True(t, lease.UpdateState(&HostState{NetInTransfer: 100, NetOutTransfer: 200}, time.Unix(10, 0)))
require.True(t, server.SetTransferSnapshots(40, 80))
handle := server.RuntimeHandle()
holder := handle.holder
holder.mu.Lock()
hourlyDone := make(chan struct{})
go func() {
server.TransferDeltaAndAdvance()
close(hourlyDone)
}()
holder.mu.Unlock()
<-hourlyDone
// When
records := 0
result, err := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), func(Transfer) error { records++; return nil })
// Then
require.NoError(t, err)
require.Equal(t, 1, records)
require.Equal(t, uint64(0), result.Transfer.In)
require.Equal(t, uint64(0), result.Transfer.Out)
require.Equal(t, uint64(51), result.ServerID)
require.Equal(t, uint64(0), server.RuntimeSnapshot().PrevTransferInSnapshot)
}
func TestServerRuntimeOwnership_restartAndTransferDoNotDuplicateWhenRestartRunsFirst(t *testing.T) {
// Given
server := &Server{Common: Common{ID: 52}, UUID: "server-52"}
InitServer(server)
lease := server.AttachStateStream(runtimeOwnershipStream{})
require.True(t, lease.UpdateState(&HostState{NetInTransfer: 100, NetOutTransfer: 200}, time.Unix(10, 0)))
require.True(t, server.SetTransferSnapshots(40, 80))
handle := server.RuntimeHandle()
result, err := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), func(Transfer) error { return nil })
require.NoError(t, err)
// When
inbound, outbound, deltaIn, deltaOut := server.TransferDeltaAndAdvance()
// Then
require.Equal(t, uint64(60), result.Transfer.In)
require.Equal(t, uint64(120), result.Transfer.Out)
require.Equal(t, uint64(0), inbound)
require.Equal(t, uint64(0), outbound)
require.Equal(t, uint64(0), deltaIn)
require.Equal(t, uint64(0), deltaOut)
}
func TestServerRuntimeOwnership_failedRestartAllowsHourlyRecordThenRetry(t *testing.T) {
// Given
server := &Server{Common: Common{ID: 53}, UUID: "server-53"}
InitServer(server)
lease := server.AttachStateStream(runtimeOwnershipStream{})
require.True(t, lease.UpdateState(&HostState{NetInTransfer: 90, NetOutTransfer: 110}, time.Unix(10, 0)))
require.True(t, server.SetTransferSnapshots(30, 50))
handle := server.RuntimeHandle()
_, err := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), func(Transfer) error { return context.Canceled })
require.ErrorIs(t, err, context.Canceled)
// When
_, _, hourlyIn, hourlyOut := server.TransferDeltaAndAdvance()
records := 0
result, retryErr := handle.ApplyHostReport(&Host{BootTime: 20}, time.Unix(20, 0), func(Transfer) error { records++; return nil })
// Then
require.NoError(t, retryErr)
require.Equal(t, uint64(60), hourlyIn)
require.Equal(t, uint64(60), hourlyOut)
require.Equal(t, 1, records)
require.Equal(t, uint64(0), result.Transfer.In)
require.Equal(t, uint64(0), result.Transfer.Out)
}
+91
View File
@@ -0,0 +1,91 @@
package model
import (
"context"
"sync"
"testing"
pb "github.com/nezhahq/nezha/proto"
)
// raceProbeStream is the smallest fake of pb.NezhaService_RequestTaskServer
// the race probe needs. We only call Send on it from the test; the embedded
// interface satisfies the rest of the contract with nil-panicking methods we
// never invoke.
type raceProbeStream struct {
pb.NezhaService_RequestTaskServer
}
func (raceProbeStream) Send(*pb.Task) error { return nil }
func (raceProbeStream) Context() context.Context { return context.Background() }
// model.Server.TaskStream is read from many goroutines (singleton cron pushes,
// transfer ApplyConfig pushes, terminal/fm proxies, dashboard rpc keepalives,
// per-server batch pushes) and written from exactly one (the gRPC RequestTask
// goroutine on every fresh agent connection). The bare-field access pattern
// `if s.TaskStream != nil { s.TaskStream.Send(...) }` is a data race on the
// interface header (two-word value) and can torn-read into a panic on a
// reconnect. This test pins down "concurrent set + send must be race-free"
// using the Go race detector — without the fix, `go test -race` reports a
// data race on TaskStream; with the fix the field is encapsulated behind
// atomic methods and the test runs clean. Without `-race` both versions are
// indistinguishable, so this test is only meaningful under the race flag —
// run it from CI as `go test -race ./model/`.
func TestServerTaskStreamConcurrentAccessIsRaceFree(t *testing.T) {
s := &Server{}
InitServer(s)
const (
writers = 4
readers = 8
rounds = 200
)
var wg sync.WaitGroup
wg.Add(writers + readers)
for i := 0; i < writers; i++ {
go func() {
defer wg.Done()
for j := 0; j < rounds; j++ {
s.SetTaskStream(raceProbeStream{})
s.SetTaskStream(nil)
}
}()
}
for i := 0; i < readers; i++ {
go func() {
defer wg.Done()
for j := 0; j < rounds; j++ {
if stream := s.GetTaskStream(); stream != nil {
_ = stream.Send(nil)
}
}
}()
}
wg.Wait()
}
func TestServerClearTaskStreamIfCurrentClearsOnlyMatchingStream(t *testing.T) {
s := &Server{}
InitServer(s)
first := &raceProbeStream{}
second := &raceProbeStream{}
s.SetTaskStream(first)
if !s.ClearTaskStreamIfCurrent(first) {
t.Fatal("matching current stream must be cleared")
}
if got := s.GetTaskStream(); got != nil {
t.Fatalf("expected cleared task stream, got %T", got)
}
s.SetTaskStream(first)
s.SetTaskStream(second)
if s.ClearTaskStreamIfCurrent(first) {
t.Fatal("stale stream cleanup must not clear a newer stream")
}
if got := s.GetTaskStream(); got != second {
t.Fatalf("expected newer stream to remain published, got %T", got)
}
}
+138
View File
@@ -0,0 +1,138 @@
package model
import (
"time"
"github.com/gin-gonic/gin"
)
// ServerTransferStatus represents the lifecycle state of a server ownership
// transfer. A transfer's life starts at Pending (server.user_id has been
// flipped to the new owner; agent still authenticates with the old owner's
// AgentSecret) and ends in exactly one of the terminal states.
type ServerTransferStatus uint8
const (
// ServerTransferStatusPending means the dashboard has flipped Server.UserID
// to the new owner and queued an ApplyConfig task to swap the agent's
// client_secret. Auth still accepts the old owner's AgentSecret for this
// UUID until verification arrives or the transfer times out.
ServerTransferStatusPending ServerTransferStatus = iota
// ServerTransferStatusVerified means the agent successfully reconnected
// using the new owner's AgentSecret. Auth no longer tolerates the old
// owner's secret on this UUID.
ServerTransferStatusVerified
// ServerTransferStatusFailed means the agent explicitly reported the
// ApplyConfig task as unsuccessful (e.g. DisableCommandExecute). The
// dashboard has rolled Server.UserID back to FromUserID.
ServerTransferStatusFailed
// ServerTransferStatusTimeout means the verification window expired
// without the agent reconnecting under the new secret. The dashboard has
// rolled Server.UserID back to FromUserID.
ServerTransferStatusTimeout
// ServerTransferStatusCancelled means an administrator cancelled the
// transfer before any verification event was observed. The dashboard has
// rolled Server.UserID back to FromUserID.
ServerTransferStatusCancelled
)
// IsTerminal reports whether the status represents a settled transfer. Only
// terminal transfers are eligible for retry and they will never be in the
// pending index.
func (s ServerTransferStatus) IsTerminal() bool {
return s != ServerTransferStatusPending
}
// ServerTransfer records a single attempt to transfer ownership of one server
// to another user. It is the source of truth for the auth-tolerance window
// during a transfer — service/rpc.authorizeAgentForUUID consults the pending
// index built from this table to decide whether to accept the old owner's
// AgentSecret on the affected UUID.
//
// Naming note: the existing model.Transfer records hourly traffic snapshots
// and is unrelated. This entity is named ServerTransfer to disambiguate.
type ServerTransfer struct {
Common
ServerID uint64 `json:"server_id" gorm:"index"`
FromUserID uint64 `json:"from_user_id"`
ToUserID uint64 `json:"to_user_id"`
InitiatorID uint64 `json:"initiator_id"`
Status ServerTransferStatus `json:"status" gorm:"index"`
LastError string `json:"last_error,omitempty"`
AckedAt *time.Time `json:"acked_at,omitempty"`
// HandshakeSecret is a per-transfer random credential that PushIfOnline
// delivers in place of the destination user's global AgentSecret. The
// agent treats it as a temporary handshake token: it rotates to this
// secret on the 10s reload, reconnects, and the dashboard's auth path
// recognises it as proof of transfer delivery (MarkVerified). It is
// scoped to this single transfer and to this single UUID — leaking it
// to the previous owner who hijacks the stream still does NOT expose
// the destination user's other agents. Never returned to API clients.
HandshakeSecret string `json:"-" gorm:"type:char(32)"`
// RevertHandshakeSecret is the same idea for the rollback path: when
// the dashboard pushes a revert ApplyConfig over a stream now held by
// the destination user, we must not embed the source user's global
// AgentSecret. Instead the agent rotates back through this token, which
// is recognised by the auth path during the revert window only.
RevertHandshakeSecret string `json:"-" gorm:"type:char(32)"`
}
// HasPermission overrides Common.HasPermission so a transfer is visible to
// 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
}
return user.ID == t.FromUserID || user.ID == t.ToUserID || user.ID == t.InitiatorID
}
// BatchMoveServerResultStatus is the per-server outcome returned by the
// batch-move endpoint. It maps to TransferStatus for transfers that were
// successfully created, plus extra synchronous-failure modes (permission,
// duplicate active transfer, missing server) that never produce a row.
type BatchMoveServerResultStatus string
const (
// BatchMoveServerResultPending: ServerTransfer row created, agent push
// in progress. Callers should watch the WS for terminal status.
BatchMoveServerResultPending BatchMoveServerResultStatus = "pending"
// BatchMoveServerResultPermissionDenied: caller cannot move this server.
BatchMoveServerResultPermissionDenied BatchMoveServerResultStatus = "permission_denied"
// BatchMoveServerResultAlreadyTransferring: server already has an in-flight
// ServerTransfer row, cancel or wait first.
BatchMoveServerResultAlreadyTransferring BatchMoveServerResultStatus = "already_transferring"
// BatchMoveServerResultServerNotFound: server id does not exist.
BatchMoveServerResultServerNotFound BatchMoveServerResultStatus = "server_not_found"
// BatchMoveServerResultSameOwner: target user already owns this server.
BatchMoveServerResultSameOwner BatchMoveServerResultStatus = "same_owner"
// BatchMoveServerResultAgentTooOld: agent build does not understand
// TaskTypeServerTransferApply, so the rotation would never complete and
// dashboard refuses to start it. Operator must upgrade the agent.
BatchMoveServerResultAgentTooOld BatchMoveServerResultStatus = "agent_too_old"
)
// BatchMoveServerResult is one entry in the batchMoveServer response, one
// per requested server id, in the same order.
type BatchMoveServerResult struct {
ServerID uint64 `json:"server_id"`
Status BatchMoveServerResultStatus `json:"status"`
TransferID uint64 `json:"transfer_id,omitempty"`
Error string `json:"error,omitempty"`
}
+243 -10
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"
@@ -26,6 +27,186 @@ const (
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 下发到 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 {
@@ -59,7 +240,7 @@ type Service struct {
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"`
HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
@@ -75,6 +256,9 @@ type Service struct {
}
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),
@@ -82,6 +266,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 {
@@ -92,6 +327,9 @@ func (m *Service) CronSpec() string {
}
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 {
@@ -128,14 +366,9 @@ func (m *Service) AfterFind(tx *gorm.DB) error {
return nil
}
// IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true
// 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 {
switch t {
case TaskTypeCommand, TaskTypeTerminalGRPC, TaskTypeUpgrade,
TaskTypeKeepalive, TaskTypeNAT, TaskTypeFM,
TaskTypeReportConfig, TaskTypeApplyConfig:
return false
default:
return true
}
return IsServiceMonitorType(t)
}
+1 -1
View File
@@ -14,7 +14,7 @@ type ServiceForm struct {
MaxLatency float32 `json:"max_latency,omitempty" default:"0.0"`
LatencyNotify bool `json:"latency_notify,omitempty" validate:"optional"`
EnableTriggerTask bool `json:"enable_trigger_task,omitempty" validate:"optional"`
EnableShowInService bool `json:"enable_show_in_service,omitempty" validate:"optional"`
HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"`
FailTriggerTasks []uint64 `json:"fail_trigger_tasks,omitempty"`
RecoverTriggerTasks []uint64 `json:"recover_trigger_tasks,omitempty"`
SkipServers map[uint64]bool `json:"skip_servers,omitempty"`
+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}")
}
}
+45
View File
@@ -0,0 +1,45 @@
package model
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestServiceMonitorTypeAllowlist(t *testing.T) {
for _, taskType := range []uint64{TaskTypeHTTPGet, TaskTypeICMPPing, TaskTypeTCPPing} {
require.True(t, IsServiceMonitorType(taskType), "probe type %d must remain allowed", taskType)
require.NoError(t, ValidateServiceMonitorType(taskType))
require.True(t, IsServiceSentinelNeeded(taskType))
}
for _, taskType := range []uint64{
0,
TaskTypeCommand,
TaskTypeApplyConfig,
TaskTypeServerTransferApply,
TaskTypeExec,
TaskTypeFsTransfer,
255,
} {
require.False(t, IsServiceMonitorType(taskType), "privileged/unknown type %d must be rejected", taskType)
require.Error(t, ValidateServiceMonitorType(taskType))
require.False(t, IsServiceSentinelNeeded(taskType))
}
}
func TestServicePersistenceAndPBRejectPrivilegedTaskTypes(t *testing.T) {
for _, taskType := range []uint8{0, TaskTypeCommand, TaskTypeApplyConfig, TaskTypeExec, 255} {
service := &Service{Type: taskType}
require.Error(t, service.BeforeSave(nil), "type %d must not be persisted", taskType)
require.Nil(t, service.PB(), "type %d must not become an Agent task", taskType)
}
service := &Service{Common: Common{ID: 7}, Type: TaskTypeTCPPing, Target: "example.invalid:443"}
require.NoError(t, service.BeforeSave(nil))
task := service.PB()
require.NotNil(t, task)
require.Equal(t, uint64(7), task.GetId())
require.Equal(t, uint64(TaskTypeTCPPing), task.GetType())
require.Equal(t, service.Target, task.GetData())
}
+14 -10
View File
@@ -8,6 +8,8 @@ type SettingForm struct {
SiteName string `json:"site_name,omitempty" minLength:"1"`
Language string `json:"language,omitempty" minLength:"2"`
InstallHost string `json:"install_host,omitempty" validate:"optional"`
DashboardHost string `json:"dashboard_host,omitempty" validate:"optional"`
ReservedHosts string `json:"reserved_hosts,omitempty" validate:"optional"`
CustomCode string `json:"custom_code,omitempty" validate:"optional"`
CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"`
WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP
@@ -19,19 +21,21 @@ type SettingForm struct {
BackgroundImageDay string `json:"background_image_day,omitempty" validate:"optional"`
BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"`
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"`
ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"`
TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"`
TelegramAdminChatID string `json:"telegram_admin_chat_id,omitempty" validate:"optional"`
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"`
ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"`
TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"`
TelegramAdminChatID string `json:"telegram_admin_chat_id,omitempty" validate:"optional"`
SMTPServer string `json:"smtp_server,omitempty" validate:"optional"`
SMTPUser string `json:"smtp_user,omitempty" validate:"optional"`
SMTPPassword string `json:"smtp_password,omitempty" validate:"optional"`
AdminEmail string `json:"admin_email,omitempty" validate:"optional"`
SMTPServer string `json:"smtp_server,omitempty" validate:"optional"`
SMTPUser string `json:"smtp_user,omitempty" validate:"optional"`
SMTPPassword string `json:"smtp_password,omitempty" validate:"optional"`
AdminEmail string `json:"admin_email,omitempty" validate:"optional"`
DomainExpiryNotificationDays string `json:"domain_expiry_notification_days,omitempty" validate:"optional"`
ServerExpiryNotificationDays string `json:"server_expiry_notification_days,omitempty" validate:"optional"`
}
type Setting struct {
+4 -2
View File
@@ -24,14 +24,16 @@ const DefaultAgentSecretLength = 32
type User struct {
Common
Username string `json:"username,omitempty" gorm:"uniqueIndex"`
Password string `json:"password,omitempty" gorm:"type:char(72)"`
Role Role `json:"role,omitempty"`
Password string `json:"-" gorm:"type:char(72)"`
Role Role `json:"role"`
AgentSecret string `json:"agent_secret,omitempty" gorm:"type:char(32)"`
RejectPassword bool `json:"reject_password,omitempty"`
TokenVersion uint64 `json:"-" gorm:"not null;default:0"`
}
type UserInfo struct {
Role Role
Username string
AgentSecret string
}
+32
View File
@@ -0,0 +1,32 @@
package model
import (
"encoding/json"
"testing"
)
// RoleAdmin is the zero value (0). The Role field must NOT use json:",omitempty"
// or an admin profile would serialize without a `role` key, and the frontend
// (which gates the admin menu on `role === 0`) would treat the admin as a
// regular user. Guard against a regression that drops the field for admins.
func TestUserRoleSerializedForAdmin(t *testing.T) {
u := User{Common: Common{ID: 1}, Username: "admin", Role: RoleAdmin}
b, err := json.Marshal(u)
if err != nil {
t.Fatalf("marshal user: %v", err)
}
var decoded map[string]json.RawMessage
if err := json.Unmarshal(b, &decoded); err != nil {
t.Fatalf("unmarshal user: %v", err)
}
raw, ok := decoded["role"]
if !ok {
t.Fatalf("admin user JSON must include the `role` field, got: %s", b)
}
if string(raw) != "0" {
t.Fatalf("admin user `role` must serialize as 0, got: %s", raw)
}
}