mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-07 17:00:20 +00:00
Compare commits
56
Commits
f7bd86af4d
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71dd21269b | ||
|
|
a20c79b75c | ||
|
|
2bd1a86429 | ||
|
|
efd3e07b90 | ||
|
|
b329755836 | ||
|
|
29f1568988 | ||
|
|
05baa2f331 | ||
|
|
8721a15b12 | ||
|
|
9881c47f0c | ||
|
|
ec637a688c | ||
|
|
8328047aa8 | ||
|
|
1420dd450c | ||
|
|
34ac50394d | ||
|
|
b71188f104 | ||
|
|
3ad985f36e | ||
|
|
40240ed02b | ||
|
|
36b742a7e9 | ||
|
|
c726930833 | ||
|
|
9dab6a5039 | ||
|
|
589f01f562 | ||
|
|
e202c2d9ca | ||
|
|
fa611ebd9e | ||
|
|
39e1c6d1be | ||
|
|
c19e8eb252 | ||
|
|
db78c34b91 | ||
|
|
e9ddd46655 | ||
|
|
09db8b4361 | ||
|
|
ed9f21e4ea | ||
|
|
f611f0a551 | ||
|
|
19d959c60d | ||
|
|
02fe54bb80 | ||
|
|
0a71cbdb63 | ||
|
|
dd0b598dc3 | ||
|
|
892619c95c | ||
|
|
cbc583dd3a | ||
|
|
f2f10ea7bc | ||
|
|
b18e085a2c | ||
|
|
06d387779d | ||
|
|
1155c17573 | ||
|
|
d19952a834 | ||
|
|
46d83d6d68 | ||
|
|
8cb86e3853 | ||
|
|
5d5830fef3 | ||
|
|
f366677744 | ||
|
|
c684388153 | ||
|
|
5665e0aaa1 | ||
|
|
5d0da5490c | ||
|
|
8c481985c2 | ||
|
|
eb48a178f8 | ||
|
|
f2e8d03667 | ||
|
|
d6d5b9804d | ||
|
|
19daf4ad3c | ||
|
|
8f1147c800 | ||
|
|
3d2b27f4ec | ||
|
|
fffbe68650 | ||
|
|
b523961cee |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
@@ -52,7 +52,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "^1.26.10"
|
||||||
|
|
||||||
- name: generate swagger docs
|
- name: generate swagger docs
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -25,13 +25,17 @@ jobs:
|
|||||||
os: [ubuntu-latest, windows-latest, macos-latest]
|
os: [ubuntu-latest, windows-latest, macos-latest]
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
steps:
|
steps:
|
||||||
|
|
||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-go@v7
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "^1.26.10"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Generate Swagger docs
|
- name: Generate Swagger docs
|
||||||
@@ -57,7 +61,7 @@ jobs:
|
|||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-go@v7
|
- uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "^1.26.10"
|
||||||
cache: false
|
cache: false
|
||||||
|
|
||||||
- name: Generate Swagger docs
|
- name: Generate Swagger docs
|
||||||
@@ -67,6 +71,8 @@ jobs:
|
|||||||
touch ./cmd/dashboard/admin-dist/a
|
touch ./cmd/dashboard/admin-dist/a
|
||||||
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
|
swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
- name: Race and shuffle tests
|
- name: Race and shuffle tests
|
||||||
run: go test -mod=readonly -race -shuffle=on -count=1 ./...
|
run: go test -mod=readonly -race -shuffle=on -count=1 ./...
|
||||||
|
|
||||||
@@ -107,7 +113,7 @@ jobs:
|
|||||||
- name: Set up Go
|
- name: Set up Go
|
||||||
uses: actions/setup-go@v7
|
uses: actions/setup-go@v7
|
||||||
with:
|
with:
|
||||||
go-version: "1.26.x"
|
go-version: "^1.26.10"
|
||||||
cache: false
|
cache: false
|
||||||
- name: Prepare Dashboard build inputs
|
- name: Prepare Dashboard build inputs
|
||||||
working-directory: nezha
|
working-directory: nezha
|
||||||
|
|||||||
+2
-3
@@ -24,8 +24,7 @@
|
|||||||
/resource/template/theme-custom
|
/resource/template/theme-custom
|
||||||
/resource/static/custom
|
/resource/static/custom
|
||||||
/cmd/dashboard/docs
|
/cmd/dashboard/docs
|
||||||
/data/*
|
/app
|
||||||
app
|
/dashboard
|
||||||
dashboard
|
|
||||||
.omo/
|
.omo/
|
||||||
|
|
||||||
+1
-1
@@ -6,4 +6,4 @@ Code in `master` branch.
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Thank you for your contribution to open source security, please submit on https://github.com/nezhahq/nezha/security
|
Thank you for your contribution to open source security. Please report vulnerabilities privately at https://github.com/nezhahq/nezha/security or by email to [hi@nai.ba](mailto:hi@nai.ba).
|
||||||
Whitespace-only changes.
@@ -172,6 +172,15 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
|
|||||||
}
|
}
|
||||||
if len(r.Rules) > 0 {
|
if len(r.Rules) > 0 {
|
||||||
for _, rule := range r.Rules {
|
for _, rule := range r.Rules {
|
||||||
|
if rule == nil {
|
||||||
|
return singleton.Localizer.ErrorT("rule is not set")
|
||||||
|
}
|
||||||
|
if !rule.IsSupportedType() {
|
||||||
|
return singleton.Localizer.ErrorT("unsupported rule type")
|
||||||
|
}
|
||||||
|
if !rule.HasSafeThresholds() {
|
||||||
|
return singleton.Localizer.ErrorT("invalid rule thresholds")
|
||||||
|
}
|
||||||
switch rule.Cover {
|
switch rule.Cover {
|
||||||
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
|
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
|
||||||
default:
|
default:
|
||||||
@@ -182,10 +191,19 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
|
|||||||
if rule.Duration < 3 {
|
if rule.Duration < 3 {
|
||||||
return singleton.Localizer.ErrorT("duration need to be at least 3")
|
return singleton.Localizer.ErrorT("duration need to be at least 3")
|
||||||
}
|
}
|
||||||
|
if rule.Duration > model.MaxAlertRuleDuration {
|
||||||
|
return singleton.Localizer.ErrorT("duration is too large")
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if !rule.HasSafeCycleUnit() {
|
||||||
|
return singleton.Localizer.ErrorT("invalid cycle unit")
|
||||||
|
}
|
||||||
if rule.CycleInterval < 1 {
|
if rule.CycleInterval < 1 {
|
||||||
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
|
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
|
||||||
}
|
}
|
||||||
|
if rule.CycleInterval > model.MaxAlertRuleCycleInterval {
|
||||||
|
return singleton.Localizer.ErrorT("cycle_interval is too large")
|
||||||
|
}
|
||||||
if rule.CycleStart == nil {
|
if rule.CycleStart == nil {
|
||||||
return singleton.Localizer.ErrorT("cycle_start is not set")
|
return singleton.Localizer.ErrorT("cycle_start is not set")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateRuleRejectsCrashPayloadsFromMember(t *testing.T) {
|
||||||
|
ctx := newMemberValidationContext(t)
|
||||||
|
cycleStart := time.Now().Add(-time.Hour)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule *model.Rule
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unknown rule type",
|
||||||
|
rule: &model.Rule{Type: "attacker_controlled", Duration: 3, Cover: model.RuleCoverAll},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duration overflows int",
|
||||||
|
rule: &model.Rule{Type: "offline", Duration: math.MaxUint64, Cover: model.RuleCoverAll},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle interval overflows int",
|
||||||
|
rule: &model.Rule{
|
||||||
|
Type: "transfer_in_cycle",
|
||||||
|
CycleStart: &cycleStart,
|
||||||
|
CycleInterval: math.MaxUint64,
|
||||||
|
Cover: model.RuleCoverAll,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil rule",
|
||||||
|
rule: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{UserID: 200},
|
||||||
|
Name: "security regression",
|
||||||
|
Rules: []*model.Rule{test.rule},
|
||||||
|
}
|
||||||
|
require.Error(t, validateRule(ctx, alert))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateRuleAcceptsSafeDurationBoundary(t *testing.T) {
|
||||||
|
ctx := newMemberValidationContext(t)
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{UserID: 200},
|
||||||
|
Name: "duration boundary",
|
||||||
|
Rules: []*model.Rule{{
|
||||||
|
Type: "offline",
|
||||||
|
Duration: model.MaxAlertRuleDuration,
|
||||||
|
Cover: model.RuleCoverAll,
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
require.NoError(t, validateRule(ctx, alert))
|
||||||
|
}
|
||||||
@@ -57,6 +57,9 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
|||||||
if err := authMiddleware.MiddlewareInit(); err != nil {
|
if err := authMiddleware.MiddlewareInit(); err != nil {
|
||||||
log.Fatal("authMiddleware.MiddlewareInit Error:" + err.Error())
|
log.Fatal("authMiddleware.MiddlewareInit Error:" + err.Error())
|
||||||
}
|
}
|
||||||
|
// Signature-only view of the auth middleware for IsDashboardCredential:
|
||||||
|
// same key and pinned algorithm, claims validation deliberately skipped.
|
||||||
|
dashboardCredentialJWTParser = newDashboardCredentialJWTParser(authMiddleware)
|
||||||
// /mcp — Model Context Protocol endpoint, authenticated by PAT only (闸 1 + 闸 2)。
|
// /mcp — Model Context Protocol endpoint, authenticated by PAT only (闸 1 + 闸 2)。
|
||||||
// 不放在 /api/v1 下:MCP client 配置 URL 更短,且 MCP transport 协议演进与 REST API
|
// 不放在 /api/v1 下:MCP client 配置 URL 更短,且 MCP transport 协议演进与 REST API
|
||||||
// 解耦。鉴权一律走 apiTokenAuthMiddleware;不接受 JWT 以避免浏览器误触。
|
// 解耦。鉴权一律走 apiTokenAuthMiddleware;不接受 JWT 以避免浏览器误触。
|
||||||
@@ -101,6 +104,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
|||||||
optionalAuth.GET("/service/:id/history", restScopeMiddleware(model.ScopeServiceRead), commonHandler(getServiceHistory))
|
optionalAuth.GET("/service/:id/history", restScopeMiddleware(model.ScopeServiceRead), commonHandler(getServiceHistory))
|
||||||
optionalAuth.GET("/server/:id/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerServices))
|
optionalAuth.GET("/server/:id/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerServices))
|
||||||
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerMetrics))
|
optionalAuth.GET("/server/:id/metrics", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerMetrics))
|
||||||
|
optionalAuth.GET("/server/:id/online-history", restScopeMiddleware(model.ScopeServerRead), commonHandler(getServerOnlineHistory))
|
||||||
|
|
||||||
// CSRF middleware applies group-wide. Safe methods short-circuit and
|
// CSRF middleware applies group-wide. Safe methods short-circuit and
|
||||||
// PAT bearer requests bypass — so the only callers gated are
|
// PAT bearer requests bypass — so the only callers gated are
|
||||||
@@ -120,10 +124,11 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
|||||||
// 资源族划分:
|
// 资源族划分:
|
||||||
// - nezha:inventory:* —— 对“服务器台账”的枚举与删除(列出 server / server-group、
|
// - nezha:inventory:* —— 对“服务器台账”的枚举与删除(列出 server / server-group、
|
||||||
// 删除 server / server-group)。这是管理后台清单管理动作。
|
// 删除 server / server-group)。这是管理后台清单管理动作。
|
||||||
// - nezha:server:* —— 对已知 server 的运行态操作(文件读写、编辑配置、
|
auth.POST("/terminal", restScopeMiddleware(model.ScopeServerExec), commonHandler(createTerminal))
|
||||||
// force-update、batch-move)。(Web Terminal 按安全要求已移除)
|
auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream))
|
||||||
auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM))
|
auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM))
|
||||||
auth.GET("/ws/file/:id", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(fmStream))
|
auth.GET("/ws/file/:id", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(fmStream))
|
||||||
|
|
||||||
auth.GET("/server", restScopeMiddleware(model.ScopeInventoryRead), listHandler(listServer))
|
auth.GET("/server", restScopeMiddleware(model.ScopeInventoryRead), listHandler(listServer))
|
||||||
auth.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer))
|
auth.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer))
|
||||||
auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig))
|
auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig))
|
||||||
@@ -131,6 +136,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
|||||||
auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServer))
|
auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServer))
|
||||||
auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer))
|
auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer))
|
||||||
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
|
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
|
||||||
|
auth.POST("/batch-lockdown/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchLockdownOnlineServers))
|
||||||
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
|
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
|
||||||
auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup))
|
auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup))
|
||||||
auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServerGroup))
|
auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServerGroup))
|
||||||
@@ -141,7 +147,6 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
|
|||||||
auth.POST("/transfer/:id/retry", restScopeMiddleware(model.ScopeTransferWrite), commonHandler(retryServerTransfer))
|
auth.POST("/transfer/:id/retry", restScopeMiddleware(model.ScopeTransferWrite), commonHandler(retryServerTransfer))
|
||||||
auth.GET("/ws/transfer", restScopeMiddleware(model.ScopeTransferRead), commonHandler(transferStream))
|
auth.GET("/ws/transfer", restScopeMiddleware(model.ScopeTransferRead), commonHandler(transferStream))
|
||||||
|
|
||||||
|
|
||||||
// service monitor
|
// service monitor
|
||||||
auth.GET("/service/list", restScopeMiddleware(model.ScopeServiceRead), listHandler(listService))
|
auth.GET("/service/list", restScopeMiddleware(model.ScopeServiceRead), listHandler(listService))
|
||||||
auth.POST("/service", restScopeMiddleware(model.ScopeServiceWrite), commonHandler(createService))
|
auth.POST("/service", restScopeMiddleware(model.ScopeServiceWrite), commonHandler(createService))
|
||||||
|
|||||||
@@ -114,6 +114,8 @@ func TestListDDNS_RedactsCredentials(t *testing.T) {
|
|||||||
Provider: "cloudflare",
|
Provider: "cloudflare",
|
||||||
AccessID: "id",
|
AccessID: "id",
|
||||||
AccessSecret: "super-secret-token",
|
AccessSecret: "super-secret-token",
|
||||||
|
WebhookURL: "https://ddns.example/update?token=embedded-secret",
|
||||||
|
WebhookRequestBody: `{"token":"embedded-secret"}`,
|
||||||
WebhookHeaders: `{"Authorization":"Bearer xxx"}`,
|
WebhookHeaders: `{"Authorization":"Bearer xxx"}`,
|
||||||
}
|
}
|
||||||
require.NoError(t, singleton.DB.Create(&p).Error)
|
require.NoError(t, singleton.DB.Create(&p).Error)
|
||||||
@@ -124,12 +126,18 @@ func TestListDDNS_RedactsCredentials(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Len(t, out, 1)
|
require.Len(t, out, 1)
|
||||||
require.Empty(t, out[0].AccessSecret, "access_secret must be redacted in list response")
|
require.Empty(t, out[0].AccessSecret, "access_secret must be redacted in list response")
|
||||||
|
require.Empty(t, out[0].WebhookURL, "webhook_url must be redacted in list response")
|
||||||
|
require.Empty(t, out[0].WebhookRequestBody, "webhook_request_body must be redacted in list response")
|
||||||
require.Empty(t, out[0].WebhookHeaders, "webhook_headers must be redacted in list response")
|
require.Empty(t, out[0].WebhookHeaders, "webhook_headers must be redacted in list response")
|
||||||
require.Equal(t, "id", out[0].AccessID, "non-secret fields must be preserved")
|
require.Equal(t, "id", out[0].AccessID, "non-secret fields must be preserved")
|
||||||
|
|
||||||
var stored model.DDNSProfile
|
var stored model.DDNSProfile
|
||||||
require.NoError(t, singleton.DB.First(&stored, p.ID).Error)
|
require.NoError(t, singleton.DB.First(&stored, p.ID).Error)
|
||||||
require.Equal(t, "super-secret-token", stored.AccessSecret, "redaction must not mutate stored data")
|
require.Equal(t, "super-secret-token", stored.AccessSecret, "redaction must not mutate stored data")
|
||||||
|
require.Equal(t, "https://ddns.example/update?token=embedded-secret", stored.WebhookURL,
|
||||||
|
"redaction must not mutate stored webhook URL")
|
||||||
|
require.Equal(t, `{"token":"embedded-secret"}`, stored.WebhookRequestBody,
|
||||||
|
"redaction must not mutate stored webhook body")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestListNotification_RedactsCredentials(t *testing.T) {
|
func TestListNotification_RedactsCredentials(t *testing.T) {
|
||||||
@@ -170,6 +178,7 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
|
|||||||
AccessID: "id",
|
AccessID: "id",
|
||||||
AccessSecret: "keep-me",
|
AccessSecret: "keep-me",
|
||||||
WebhookURL: "http://127.0.0.1/",
|
WebhookURL: "http://127.0.0.1/",
|
||||||
|
WebhookRequestBody: `{"X-Token":"keep-body"}`,
|
||||||
WebhookMethod: 1,
|
WebhookMethod: 1,
|
||||||
WebhookHeaders: `{"X-Token":"keep-header"}`,
|
WebhookHeaders: `{"X-Token":"keep-header"}`,
|
||||||
}
|
}
|
||||||
@@ -181,10 +190,11 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
|
|||||||
"provider": "webhook",
|
"provider": "webhook",
|
||||||
"access_id": "id",
|
"access_id": "id",
|
||||||
"access_secret": "",
|
"access_secret": "",
|
||||||
"webhook_url": "http://127.0.0.1/",
|
"webhook_url": "",
|
||||||
"webhook_method": 1,
|
"webhook_method": 1,
|
||||||
"webhook_request_type": 1,
|
"webhook_request_type": 1,
|
||||||
"webhook_headers": "",
|
"webhook_headers": "",
|
||||||
|
"webhook_request_body": "",
|
||||||
"max_retries": 3,
|
"max_retries": 3,
|
||||||
})
|
})
|
||||||
c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}}
|
c.Params = gin.Params{{Key: "id", Value: itoa(existing.ID)}}
|
||||||
@@ -197,6 +207,10 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
|
|||||||
require.Equal(t, "keep-me", after.AccessSecret, "empty submitted secret must preserve stored value")
|
require.Equal(t, "keep-me", after.AccessSecret, "empty submitted secret must preserve stored value")
|
||||||
require.Equal(t, `{"X-Token":"keep-header"}`, after.WebhookHeaders,
|
require.Equal(t, `{"X-Token":"keep-header"}`, after.WebhookHeaders,
|
||||||
"empty submitted headers must preserve stored value")
|
"empty submitted headers must preserve stored value")
|
||||||
|
require.Equal(t, "http://127.0.0.1/", after.WebhookURL,
|
||||||
|
"empty submitted webhook URL must preserve stored value")
|
||||||
|
require.Equal(t, `{"X-Token":"keep-body"}`, after.WebhookRequestBody,
|
||||||
|
"empty submitted webhook body must preserve stored value")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateDDNS_NonEmptySecretOverwrites(t *testing.T) {
|
func TestUpdateDDNS_NonEmptySecretOverwrites(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v4"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
ginjwt "github.com/appleboy/gin-jwt/v2"
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dashboardCredentialJWTParser verifies that a JWT was signed by this
|
||||||
|
// dashboard. It is derived from the auth middleware created in routers() and
|
||||||
|
// exists only for dashboard-credential classification; it must never
|
||||||
|
// authenticate dashboard requests on its own.
|
||||||
|
var dashboardCredentialJWTParser *ginjwt.GinJWTMiddleware
|
||||||
|
|
||||||
|
// newDashboardCredentialJWTParser derives a signature-only parser from the
|
||||||
|
// dashboard auth middleware. It keeps the exact key value and the pinned HS256
|
||||||
|
// signing algorithm of initParams() — there is no second read of
|
||||||
|
// singleton.Conf.JWTSecretKey — and disables claims validation so expiry does
|
||||||
|
// not change the verdict: an expired token is still proof the dashboard issued
|
||||||
|
// it, and keeping expired dashboard tokens out of the NAT tunnel prevents a
|
||||||
|
// careless backend from treating "any JWT" as its own credential. The live
|
||||||
|
// auth middleware keeps full claims validation; dashboard authentication is
|
||||||
|
// not relaxed by this parser in any way.
|
||||||
|
func newDashboardCredentialJWTParser(authMiddleware *ginjwt.GinJWTMiddleware) *ginjwt.GinJWTMiddleware {
|
||||||
|
parser := *authMiddleware
|
||||||
|
parser.ParseOptions = []jwt.ParserOption{jwt.WithoutClaimsValidation()}
|
||||||
|
return &parser
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDashboardCredential reports whether an Authorization header value was
|
||||||
|
// issued by this dashboard: a panel-signed JWT or a panel API token (PAT).
|
||||||
|
//
|
||||||
|
// NAT ingress uses this to enforce the tunnel credential invariant: values
|
||||||
|
// classified as dashboard-issued are stripped before the request is tunneled
|
||||||
|
// to the agent, while foreign credentials are forwarded untouched because the
|
||||||
|
// NAT backend may authenticate with credentials of its own.
|
||||||
|
//
|
||||||
|
// Decision policy mirrors the dashboard's own auth middlewares: only the
|
||||||
|
// case-sensitive "Bearer " scheme is considered; the value after the scheme is
|
||||||
|
// classified by IsDashboardCredentialValue, which is also the entry point for
|
||||||
|
// the panel's scheme-less TokenLookup channels. Only a deterministic proof
|
||||||
|
// that the value was not issued by this dashboard (scheme mismatch, unknown
|
||||||
|
// PAT hash, failed signature verification) returns false.
|
||||||
|
func IsDashboardCredential(authz string) bool {
|
||||||
|
raw := strings.TrimSpace(authz)
|
||||||
|
if !strings.HasPrefix(raw, "Bearer ") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return IsDashboardCredentialValue(strings.TrimPrefix(raw, "Bearer "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDashboardCredentialValue reports whether a raw credential value — the part
|
||||||
|
// after the "Bearer " scheme, exactly as the panel's other TokenLookup
|
||||||
|
// channels carry it (the ?token= query parameter and the nz-jwt cookie, see
|
||||||
|
// initParams) — was issued by this dashboard: a panel-signed JWT or a panel
|
||||||
|
// API token (PAT).
|
||||||
|
//
|
||||||
|
// Failure policy is identical to IsDashboardCredential: whenever the
|
||||||
|
// classification cannot be completed (parser or config not ready, database
|
||||||
|
// failure) the value is conservatively reported as a dashboard credential so
|
||||||
|
// the caller strips it; only a deterministic proof that the value was not
|
||||||
|
// issued by this dashboard (unknown PAT hash, failed signature verification)
|
||||||
|
// returns false.
|
||||||
|
func IsDashboardCredentialValue(value string) bool {
|
||||||
|
classified := ClassifyDashboardCredentialValues(context.Background(), []string{value})
|
||||||
|
if len(classified) != 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return classified[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyDashboardCredentialValues classifies a bounded request batch. JWTs
|
||||||
|
// are checked independently, while all PAT-shaped values are hashed and
|
||||||
|
// resolved with one silent IN query. This prevents attacker-controlled NAT
|
||||||
|
// query parameters or cookies from amplifying one HTTP request into thousands
|
||||||
|
// of SQLite queries and record-not-found log entries.
|
||||||
|
func ClassifyDashboardCredentialValues(ctx context.Context, values []string) []bool {
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
results := make([]bool, len(values))
|
||||||
|
patIndexes := make(map[string][]int)
|
||||||
|
for i, value := range values {
|
||||||
|
plaintext := strings.TrimSpace(value)
|
||||||
|
if strings.HasPrefix(plaintext, model.APITokenPrefix) {
|
||||||
|
hash := model.HashAPIToken(plaintext)
|
||||||
|
patIndexes[hash] = append(patIndexes[hash], i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
results[i] = isDashboardSignedJWT(plaintext)
|
||||||
|
}
|
||||||
|
if len(patIndexes) == 0 {
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
if singleton.DB == nil {
|
||||||
|
logDashboardCredentialUnclassifiable("api token lookup unavailable")
|
||||||
|
for _, indexes := range patIndexes {
|
||||||
|
for _, i := range indexes {
|
||||||
|
results[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
hashes := make([]string, 0, len(patIndexes))
|
||||||
|
for hash := range patIndexes {
|
||||||
|
hashes = append(hashes, hash)
|
||||||
|
}
|
||||||
|
type tokenHashRow struct {
|
||||||
|
TokenHash string
|
||||||
|
}
|
||||||
|
var rows []tokenHashRow
|
||||||
|
err := singleton.DB.WithContext(ctx).
|
||||||
|
Session(&gorm.Session{Logger: logger.Discard}).
|
||||||
|
Model(&model.APIToken{}).
|
||||||
|
Select("token_hash").
|
||||||
|
Where("token_hash IN ?", hashes).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
logDashboardCredentialUnclassifiable("api token lookup failed")
|
||||||
|
for _, indexes := range patIndexes {
|
||||||
|
for _, i := range indexes {
|
||||||
|
results[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
for _, i := range patIndexes[row.TokenHash] {
|
||||||
|
results[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDashboardSignedJWT verifies the token signature with the dashboard's
|
||||||
|
// pinned algorithm and key. Claims validation is disabled on purpose (see
|
||||||
|
// newDashboardCredentialJWTParser): expiry does not change who issued the
|
||||||
|
// token.
|
||||||
|
func isDashboardSignedJWT(token string) bool {
|
||||||
|
if dashboardCredentialJWTParser == nil {
|
||||||
|
// routers() has not run yet (startup incomplete): fail closed.
|
||||||
|
logDashboardCredentialUnclassifiable("dashboard credential parser unavailable")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
_, err := dashboardCredentialJWTParser.ParseTokenString(token)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// logDashboardCredentialUnclassifiable records a classification failure. The
|
||||||
|
// credential value itself is never included in the message.
|
||||||
|
func logDashboardCredentialUnclassifiable(reason string) {
|
||||||
|
log.Printf("NEZHA>> NAT ingress: credential value could not be classified (%s); stripping it as a precaution", reason)
|
||||||
|
}
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/cmd/dashboard/rpc"
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/proto"
|
||||||
|
rpcService "github.com/nezhahq/nezha/service/rpc"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupNATFlowTest extends setupDashboardCredentialTest with the minimal
|
||||||
|
// ServeNAT environment: a registered server with a task stream and a fresh
|
||||||
|
// handler singleton, mirroring the rpc package's serveNAT fixture.
|
||||||
|
func setupNATFlowTest(t *testing.T) (*rpcService.NezhaHandler, *model.Server, *natFlowTaskStream) {
|
||||||
|
t.Helper()
|
||||||
|
setupDashboardCredentialTest(t)
|
||||||
|
|
||||||
|
require.NoError(t, singleton.DB.AutoMigrate(&model.Server{}))
|
||||||
|
originalServerShared, originalHandler := singleton.ServerShared, rpcService.NezhaHandlerSingleton
|
||||||
|
t.Cleanup(func() {
|
||||||
|
singleton.ServerShared, rpcService.NezhaHandlerSingleton = originalServerShared, originalHandler
|
||||||
|
})
|
||||||
|
|
||||||
|
serverRow := &model.Server{Common: model.Common{ID: 7}, UUID: "nat-credential-flow-test", Name: "nat-credential-flow-test"}
|
||||||
|
require.NoError(t, singleton.DB.Create(serverRow).Error)
|
||||||
|
singleton.ServerShared = singleton.NewServerClass()
|
||||||
|
handler := rpcService.NewNezhaHandler()
|
||||||
|
rpcService.NezhaHandlerSingleton = handler
|
||||||
|
|
||||||
|
server, ok := singleton.ServerShared.Get(serverRow.ID)
|
||||||
|
require.True(t, ok)
|
||||||
|
taskStream := &natFlowTaskStream{}
|
||||||
|
server.SetTaskStream(taskStream)
|
||||||
|
|
||||||
|
// Wire the gate exactly as cmd/dashboard/main.go does, with the real
|
||||||
|
// classifiers built by setupDashboardCredentialTest.
|
||||||
|
rpc.SetNATDashboardCredentialGate(ClassifyDashboardCredentialValues)
|
||||||
|
t.Cleanup(func() { rpc.SetNATDashboardCredentialGate(nil) })
|
||||||
|
|
||||||
|
return handler, server, taskStream
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServeNATWithRealClassifierStripsDashboardCredentialsFromAllChannels pins
|
||||||
|
// the main() wiring contract end to end: with the real classifiers wired as
|
||||||
|
// main() does, ServeNAT forwards a request whose dashboard-issued credentials
|
||||||
|
// — Authorization header, ?token= query parameter, nz-jwt cookie — are all
|
||||||
|
// stripped, while foreign values and untouched query/cookie data survive
|
||||||
|
// byte-for-byte.
|
||||||
|
func TestServeNATWithRealClassifierStripsDashboardCredentialsFromAllChannels(t *testing.T) {
|
||||||
|
handler, server, taskStream := setupNATFlowTest(t)
|
||||||
|
|
||||||
|
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
|
||||||
|
foreignJWT := mintForeignJWT(t)
|
||||||
|
// No api_tokens row hashes to this PAT, so the classifier proves it was
|
||||||
|
// not issued by this dashboard and it must survive the tunnel.
|
||||||
|
foreignPAT := model.APITokenPrefix + "foreign-issued-pat"
|
||||||
|
dashboardPAT := model.APITokenPrefix + "dashboard-issued-pat"
|
||||||
|
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
||||||
|
UserID: 1,
|
||||||
|
Name: "nat-flow",
|
||||||
|
TokenHash: model.HashAPIToken(dashboardPAT),
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
connection := &natFlowConn{closed: make(chan struct{})}
|
||||||
|
agent := &natFlowAgent{writeDone: make(chan struct{})}
|
||||||
|
writer := &natFlowWriter{conn: connection}
|
||||||
|
request := &http.Request{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + dashboardJWT + "&token=" + url.QueryEscape(foreignJWT) +
|
||||||
|
"&token=" + foreignPAT + "&token=" + dashboardPAT},
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: http.NoBody,
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+dashboardJWT)
|
||||||
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+dashboardJWT+"; nz-jwt="+foreignJWT+"; nz-jwt="+foreignPAT)
|
||||||
|
request.Header.Set("X-Ordinary-Nat", "ordinary")
|
||||||
|
taskStream.onSend = func(task *proto.Task) error {
|
||||||
|
var nat model.TaskNAT
|
||||||
|
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return handler.AgentConnected(nat.StreamID, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
rpc.ServeNAT(writer, request, &model.NAT{ServerID: server.ID, Host: "target.example"})
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-agent.writeDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("agent did not receive the transferred NAT request")
|
||||||
|
}
|
||||||
|
require.NoError(t, connection.Close())
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ServeNAT did not finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarded := string(agent.writtenBytes())
|
||||||
|
// Dashboard credentials are gone from every channel.
|
||||||
|
require.NotContains(t, forwarded, "Authorization:")
|
||||||
|
require.NotContains(t, forwarded, dashboardJWT)
|
||||||
|
require.NotContains(t, forwarded, dashboardPAT)
|
||||||
|
// Foreign credentials and untouched data survive byte-for-byte.
|
||||||
|
require.Contains(t, forwarded, "POST /nat?keep=1&token="+url.QueryEscape(foreignJWT)+"&token="+foreignPAT+" HTTP/1.1")
|
||||||
|
require.Contains(t, forwarded, "Cookie: sid=abc; nz-jwt="+foreignJWT+"; nz-jwt="+foreignPAT)
|
||||||
|
require.Contains(t, forwarded, "X-Ordinary-Nat: ordinary")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServeNATWithRealClassifierKeepsForeignCredentials pins that the real
|
||||||
|
// classifier deterministically proves foreign values were not issued by this
|
||||||
|
// dashboard and leaves every channel untouched.
|
||||||
|
func TestServeNATWithRealClassifierKeepsForeignCredentials(t *testing.T) {
|
||||||
|
handler, server, taskStream := setupNATFlowTest(t)
|
||||||
|
|
||||||
|
foreignJWT := mintForeignJWT(t)
|
||||||
|
connection := &natFlowConn{closed: make(chan struct{})}
|
||||||
|
agent := &natFlowAgent{writeDone: make(chan struct{})}
|
||||||
|
writer := &natFlowWriter{conn: connection}
|
||||||
|
request := &http.Request{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: "token=" + foreignJWT},
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: http.NoBody,
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+foreignJWT)
|
||||||
|
request.Header.Set("Cookie", "nz-jwt="+foreignJWT)
|
||||||
|
taskStream.onSend = func(task *proto.Task) error {
|
||||||
|
var nat model.TaskNAT
|
||||||
|
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return handler.AgentConnected(nat.StreamID, agent)
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
rpc.ServeNAT(writer, request, &model.NAT{ServerID: server.ID, Host: "target.example"})
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-agent.writeDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("agent did not receive the transferred NAT request")
|
||||||
|
}
|
||||||
|
require.NoError(t, connection.Close())
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ServeNAT did not finish")
|
||||||
|
}
|
||||||
|
|
||||||
|
forwarded := string(agent.writtenBytes())
|
||||||
|
require.Contains(t, forwarded, "POST /nat?token="+foreignJWT+" HTTP/1.1")
|
||||||
|
require.Contains(t, forwarded, "Authorization: Bearer "+foreignJWT)
|
||||||
|
require.Contains(t, forwarded, "Cookie: nz-jwt="+foreignJWT)
|
||||||
|
}
|
||||||
|
|
||||||
|
type natFlowTaskStream struct {
|
||||||
|
onSend func(*proto.Task) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (stream *natFlowTaskStream) Send(task *proto.Task) error {
|
||||||
|
if stream.onSend != nil {
|
||||||
|
return stream.onSend(task)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (*natFlowTaskStream) Recv() (*proto.TaskResult, error) { return nil, io.EOF }
|
||||||
|
func (*natFlowTaskStream) SetHeader(metadata.MD) error { return nil }
|
||||||
|
func (*natFlowTaskStream) SendHeader(metadata.MD) error { return nil }
|
||||||
|
func (*natFlowTaskStream) SetTrailer(metadata.MD) {}
|
||||||
|
func (*natFlowTaskStream) Context() context.Context { return context.Background() }
|
||||||
|
func (*natFlowTaskStream) SendMsg(any) error { return nil }
|
||||||
|
func (*natFlowTaskStream) RecvMsg(any) error { return io.EOF }
|
||||||
|
|
||||||
|
type natFlowWriter struct{ conn *natFlowConn }
|
||||||
|
|
||||||
|
func (writer *natFlowWriter) Header() http.Header { return http.Header{} }
|
||||||
|
func (writer *natFlowWriter) Write(data []byte) (int, error) { return len(data), nil }
|
||||||
|
func (writer *natFlowWriter) WriteHeader(int) {}
|
||||||
|
func (writer *natFlowWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
go func() { _, _ = writer.conn.Read(make([]byte, 1)) }()
|
||||||
|
return writer.conn, bufio.NewReadWriter(bufio.NewReader(writer.conn), bufio.NewWriter(writer.conn)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type natFlowConn struct {
|
||||||
|
closed chan struct{}
|
||||||
|
closeOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (conn *natFlowConn) Read([]byte) (int, error) {
|
||||||
|
<-conn.closed
|
||||||
|
return 0, io.EOF
|
||||||
|
}
|
||||||
|
func (conn *natFlowConn) Write(data []byte) (int, error) { return len(data), nil }
|
||||||
|
func (conn *natFlowConn) Close() error {
|
||||||
|
conn.closeOnce.Do(func() { close(conn.closed) })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (conn *natFlowConn) LocalAddr() net.Addr { return natFlowAddr{} }
|
||||||
|
func (conn *natFlowConn) RemoteAddr() net.Addr { return natFlowAddr{} }
|
||||||
|
func (conn *natFlowConn) SetDeadline(time.Time) error { return nil }
|
||||||
|
func (conn *natFlowConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
|
func (conn *natFlowConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
|
type natFlowAddr struct{}
|
||||||
|
|
||||||
|
func (natFlowAddr) Network() string { return "test" }
|
||||||
|
func (natFlowAddr) String() string { return "test" }
|
||||||
|
|
||||||
|
type natFlowAgent struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
written bytes.Buffer
|
||||||
|
writeDone chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (agent *natFlowAgent) Read([]byte) (int, error) { return 0, io.EOF }
|
||||||
|
func (agent *natFlowAgent) Write(data []byte) (int, error) {
|
||||||
|
agent.mu.Lock()
|
||||||
|
agent.written.Write(data)
|
||||||
|
agent.mu.Unlock()
|
||||||
|
agent.once.Do(func() { close(agent.writeDone) })
|
||||||
|
return len(data), nil
|
||||||
|
}
|
||||||
|
func (*natFlowAgent) Close() error { return nil }
|
||||||
|
func (agent *natFlowAgent) writtenBytes() []byte {
|
||||||
|
agent.mu.Lock()
|
||||||
|
defer agent.mu.Unlock()
|
||||||
|
return append([]byte(nil), agent.written.Bytes()...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/appleboy/gin-jwt/v2"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupDashboardCredentialTest(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
originalConf, originalDB, originalParser := singleton.Conf, singleton.DB, dashboardCredentialJWTParser
|
||||||
|
t.Cleanup(func() {
|
||||||
|
singleton.Conf, singleton.DB, dashboardCredentialJWTParser = originalConf, originalDB, originalParser
|
||||||
|
})
|
||||||
|
|
||||||
|
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
|
||||||
|
singleton.Conf.JWTSecretKey = "dashboard-credential-classification-test-key"
|
||||||
|
singleton.Conf.JWTTimeout = 1
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
require.NoError(t, db.AutoMigrate(&model.APIToken{}))
|
||||||
|
singleton.DB = db
|
||||||
|
|
||||||
|
// Same construction path as routers(): jwt.New(initParams()).
|
||||||
|
authMiddleware, err := jwt.New(initParams())
|
||||||
|
require.NoError(t, err)
|
||||||
|
dashboardCredentialJWTParser = newDashboardCredentialJWTParser(authMiddleware)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mintDashboardCredentialJWT signs a JWT with the dashboard key through
|
||||||
|
// initParams(), the same key source the production verifier uses. A negative
|
||||||
|
// timeout mints an already-expired token.
|
||||||
|
func mintDashboardCredentialJWT(t *testing.T, timeout time.Duration) string {
|
||||||
|
t.Helper()
|
||||||
|
params := initParams()
|
||||||
|
params.Timeout = timeout
|
||||||
|
mw, err := jwt.New(params)
|
||||||
|
require.NoError(t, err)
|
||||||
|
token, _, err := mw.TokenGenerator(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func mintForeignJWT(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
params := initParams()
|
||||||
|
params.Key = []byte("foreign-signing-key-not-held-by-the-dashboard")
|
||||||
|
mw, err := jwt.New(params)
|
||||||
|
require.NoError(t, err)
|
||||||
|
token, _, err := mw.TokenGenerator(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDashboardCredentialClassification(t *testing.T) {
|
||||||
|
setupDashboardCredentialTest(t)
|
||||||
|
|
||||||
|
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
|
||||||
|
expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour)
|
||||||
|
foreignJWT := mintForeignJWT(t)
|
||||||
|
|
||||||
|
dashboardPAT := model.APITokenPrefix + "dashboard-credential-pat"
|
||||||
|
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
||||||
|
UserID: 1,
|
||||||
|
Name: "nat-classification",
|
||||||
|
TokenHash: model.HashAPIToken(dashboardPAT),
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) {
|
||||||
|
require.True(t, IsDashboardCredential("Bearer "+dashboardJWT))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) {
|
||||||
|
// Signature verification ignores exp on purpose: an expired token is
|
||||||
|
// still proof the dashboard issued it.
|
||||||
|
require.True(t, IsDashboardCredential("Bearer "+expiredDashboardJWT))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dashboard api token is a dashboard credential", func(t *testing.T) {
|
||||||
|
require.True(t, IsDashboardCredential("Bearer "+dashboardPAT))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredential("Bearer "+foreignJWT))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unknown api token is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unknown-to-the-dashboard"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("random string is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredential("Bearer random-not-a-token"))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non bearer scheme is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredential("Basic "+dashboardJWT))
|
||||||
|
// Scheme matching is case-sensitive, as in both dashboard auth middlewares.
|
||||||
|
require.False(t, IsDashboardCredential("bearer "+dashboardJWT))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("empty or missing header is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredential(""))
|
||||||
|
require.False(t, IsDashboardCredential("Bearer "))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsDashboardCredentialValueClassification pins the scheme-less classifier
|
||||||
|
// that backs IsDashboardCredential and drives the ?token= query parameter and
|
||||||
|
// nz-jwt cookie channels at NAT ingress (see IsDashboardCredentialValue).
|
||||||
|
func TestIsDashboardCredentialValueClassification(t *testing.T) {
|
||||||
|
setupDashboardCredentialTest(t)
|
||||||
|
|
||||||
|
dashboardJWT := mintDashboardCredentialJWT(t, time.Hour)
|
||||||
|
expiredDashboardJWT := mintDashboardCredentialJWT(t, -time.Hour)
|
||||||
|
foreignJWT := mintForeignJWT(t)
|
||||||
|
|
||||||
|
dashboardPAT := model.APITokenPrefix + "dashboard-credential-value-pat"
|
||||||
|
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
||||||
|
UserID: 1,
|
||||||
|
Name: "nat-value-classification",
|
||||||
|
TokenHash: model.HashAPIToken(dashboardPAT),
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
t.Run("dashboard signed jwt is a dashboard credential", func(t *testing.T) {
|
||||||
|
require.True(t, IsDashboardCredentialValue(dashboardJWT))
|
||||||
|
})
|
||||||
|
t.Run("expired dashboard signed jwt is still a dashboard credential", func(t *testing.T) {
|
||||||
|
require.True(t, IsDashboardCredentialValue(expiredDashboardJWT))
|
||||||
|
})
|
||||||
|
t.Run("dashboard api token is a dashboard credential", func(t *testing.T) {
|
||||||
|
require.True(t, IsDashboardCredentialValue(dashboardPAT))
|
||||||
|
})
|
||||||
|
t.Run("foreign signed jwt is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredentialValue(foreignJWT))
|
||||||
|
})
|
||||||
|
t.Run("unknown api token is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredentialValue(model.APITokenPrefix+"unknown-to-the-dashboard"))
|
||||||
|
})
|
||||||
|
t.Run("random string is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredentialValue("random-not-a-token"))
|
||||||
|
})
|
||||||
|
t.Run("empty value is not a dashboard credential", func(t *testing.T) {
|
||||||
|
require.False(t, IsDashboardCredentialValue(""))
|
||||||
|
})
|
||||||
|
t.Run("bearer scheme is not part of the value contract", func(t *testing.T) {
|
||||||
|
// The query and cookie channels carry the bare value; a value that
|
||||||
|
// itself starts with "Bearer " is not something the panel issues and
|
||||||
|
// fails signature verification.
|
||||||
|
require.False(t, IsDashboardCredentialValue("Bearer "+dashboardJWT))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClassifyDashboardCredentialValuesBatchesPATLookup(t *testing.T) {
|
||||||
|
setupDashboardCredentialTest(t)
|
||||||
|
|
||||||
|
dashboardPAT := model.APITokenPrefix + "dashboard-batch-pat"
|
||||||
|
require.NoError(t, singleton.DB.Create(&model.APIToken{
|
||||||
|
UserID: 1,
|
||||||
|
Name: "nat-batch-classification",
|
||||||
|
TokenHash: model.HashAPIToken(dashboardPAT),
|
||||||
|
}).Error)
|
||||||
|
|
||||||
|
queryCount := 0
|
||||||
|
const callbackName = "test:count-dashboard-credential-batch-query"
|
||||||
|
require.NoError(t, singleton.DB.Callback().Query().Before("gorm:query").Register(callbackName, func(*gorm.DB) {
|
||||||
|
queryCount++
|
||||||
|
}))
|
||||||
|
t.Cleanup(func() { singleton.DB.Callback().Query().Remove(callbackName) })
|
||||||
|
|
||||||
|
values := []string{
|
||||||
|
dashboardPAT,
|
||||||
|
model.APITokenPrefix + "unknown-one",
|
||||||
|
dashboardPAT,
|
||||||
|
model.APITokenPrefix + "unknown-two",
|
||||||
|
}
|
||||||
|
classified := ClassifyDashboardCredentialValues(context.Background(), values)
|
||||||
|
|
||||||
|
require.Equal(t, []bool{true, false, true, false}, classified)
|
||||||
|
require.Equal(t, 1, queryCount, "all PAT hashes in one request must use one database query")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDashboardCredentialFailsClosedWhenParserUnavailable(t *testing.T) {
|
||||||
|
setupDashboardCredentialTest(t)
|
||||||
|
dashboardCredentialJWTParser = nil
|
||||||
|
|
||||||
|
token := mintDashboardCredentialJWT(t, time.Hour)
|
||||||
|
require.True(t, IsDashboardCredential("Bearer "+token))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDashboardCredentialFailsClosedWhenAPITokenLookupUnavailable(t *testing.T) {
|
||||||
|
setupDashboardCredentialTest(t)
|
||||||
|
singleton.DB = nil
|
||||||
|
|
||||||
|
require.True(t, IsDashboardCredential("Bearer "+model.APITokenPrefix+"unresolvable"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsDashboardCredentialFailsClosedOnDatabaseError(t *testing.T) {
|
||||||
|
setupDashboardCredentialTest(t)
|
||||||
|
sqlDB, err := singleton.DB.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
originalOutput := log.Writer()
|
||||||
|
log.SetOutput(&logs)
|
||||||
|
t.Cleanup(func() { log.SetOutput(originalOutput) })
|
||||||
|
|
||||||
|
plaintext := model.APITokenPrefix + "unresolvable-after-db-failure"
|
||||||
|
require.True(t, IsDashboardCredential("Bearer "+plaintext))
|
||||||
|
// The credential content itself must never reach the log.
|
||||||
|
require.NotContains(t, logs.String(), plaintext)
|
||||||
|
}
|
||||||
@@ -34,6 +34,8 @@ func listDDNS(c *gin.Context) ([]*model.DDNSProfile, error) {
|
|||||||
// 不影响 singleton 内原始数据。
|
// 不影响 singleton 内原始数据。
|
||||||
for _, p := range ddnsProfiles {
|
for _, p := range ddnsProfiles {
|
||||||
p.AccessSecret = ""
|
p.AccessSecret = ""
|
||||||
|
p.WebhookURL = ""
|
||||||
|
p.WebhookRequestBody = ""
|
||||||
p.WebhookHeaders = ""
|
p.WebhookHeaders = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,10 +146,8 @@ func updateDDNS(c *gin.Context) (any, error) {
|
|||||||
p.Provider = df.Provider
|
p.Provider = df.Provider
|
||||||
p.Domains = df.Domains
|
p.Domains = df.Domains
|
||||||
p.AccessID = df.AccessID
|
p.AccessID = df.AccessID
|
||||||
p.WebhookURL = df.WebhookURL
|
|
||||||
p.WebhookMethod = df.WebhookMethod
|
p.WebhookMethod = df.WebhookMethod
|
||||||
p.WebhookRequestType = df.WebhookRequestType
|
p.WebhookRequestType = df.WebhookRequestType
|
||||||
p.WebhookRequestBody = df.WebhookRequestBody
|
|
||||||
|
|
||||||
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
|
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
|
||||||
if df.AccessSecret != "" {
|
if df.AccessSecret != "" {
|
||||||
@@ -156,6 +156,12 @@ func updateDDNS(c *gin.Context) (any, error) {
|
|||||||
if df.WebhookHeaders != "" {
|
if df.WebhookHeaders != "" {
|
||||||
p.WebhookHeaders = df.WebhookHeaders
|
p.WebhookHeaders = df.WebhookHeaders
|
||||||
}
|
}
|
||||||
|
if df.WebhookURL != "" {
|
||||||
|
p.WebhookURL = df.WebhookURL
|
||||||
|
}
|
||||||
|
if df.WebhookRequestBody != "" {
|
||||||
|
p.WebhookRequestBody = df.WebhookRequestBody
|
||||||
|
}
|
||||||
|
|
||||||
for n, domain := range p.Domains {
|
for n, domain := range p.Domains {
|
||||||
// IDN to ASCII
|
// IDN to ASCII
|
||||||
|
|||||||
@@ -47,12 +47,13 @@ func GetDomainList(c *gin.Context) (any, error) {
|
|||||||
if d.BillingData != nil {
|
if d.BillingData != nil {
|
||||||
var billing model.BillingDataMod
|
var billing model.BillingDataMod
|
||||||
if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" {
|
if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" {
|
||||||
if endDate, err := time.Parse(time.RFC3339, billing.EndDate); err == nil {
|
if endDate, err := singleton.ParseFlexibleDate(billing.EndDate); err == nil {
|
||||||
daysLeft := int(time.Until(endDate).Hours() / 24)
|
daysLeft := int(time.Until(endDate).Hours() / 24)
|
||||||
apiDomain.ExpiresInDays = &daysLeft
|
apiDomain.ExpiresInDays = &daysLeft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response = append(response, apiDomain)
|
response = append(response, apiDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,19 @@ import (
|
|||||||
"github.com/nezhahq/nezha/service/singleton"
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// The official file-manager client uploads in 1 MiB WebSocket messages. Cap
|
||||||
|
// each complete message at that protocol boundary so gorilla/websocket cannot
|
||||||
|
// buffer an attacker-sized frame before the IO stream relay sees it.
|
||||||
|
const fileManagerWebSocketInputLimit int64 = 1024 * 1024
|
||||||
|
|
||||||
|
type websocketReadLimiter interface {
|
||||||
|
SetReadLimit(limit int64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func limitFileManagerWebSocketInput(conn websocketReadLimiter) {
|
||||||
|
conn.SetReadLimit(fileManagerWebSocketInputLimit)
|
||||||
|
}
|
||||||
|
|
||||||
// Create FM session
|
// Create FM session
|
||||||
// @Summary Create FM session
|
// @Summary Create FM session
|
||||||
// @Description Create an "attached" FM. It is advised to only call this within a terminal session.
|
// @Description Create an "attached" FM. It is advised to only call this within a terminal session.
|
||||||
@@ -44,6 +57,10 @@ func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
|
|||||||
return nil, singleton.Localizer.ErrorT("permission denied")
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if server.IsTelemetryOnly() {
|
||||||
|
return nil, singleton.Localizer.ErrorT("file manager is disabled: server is in telemetry-only mode")
|
||||||
|
}
|
||||||
|
|
||||||
streamId, err := uuid.GenerateUUID()
|
streamId, err := uuid.GenerateUUID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -99,6 +116,7 @@ func fmStream(c *gin.Context) (any, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, newWsError("%v", err)
|
return nil, newWsError("%v", err)
|
||||||
}
|
}
|
||||||
|
limitFileManagerWebSocketInput(wsConn)
|
||||||
conn := websocketx.NewConn(wsConn)
|
conn := websocketx.NewConn(wsConn)
|
||||||
pingTransport := newWebsocketPingTransport(conn, wsConn.Close)
|
pingTransport := newWebsocketPingTransport(conn, wsConn.Close)
|
||||||
stopPing := startWebsocketPingTicker(c.Request.Context(), time.Second*10, pingTransport)
|
stopPing := startWebsocketPingTicker(c.Request.Context(), time.Second*10, pingTransport)
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import (
|
|||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
|
|
||||||
"github.com/nezhahq/nezha/model"
|
"github.com/nezhahq/nezha/model"
|
||||||
|
|
||||||
"github.com/nezhahq/nezha/pkg/idcodec"
|
"github.com/nezhahq/nezha/pkg/idcodec"
|
||||||
"github.com/nezhahq/nezha/pkg/utils"
|
"github.com/nezhahq/nezha/pkg/utils"
|
||||||
"github.com/nezhahq/nezha/service/singleton"
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
@@ -130,8 +130,6 @@ func identityHandler() func(c *gin.Context) any {
|
|||||||
}
|
}
|
||||||
claimUID, err := idcodec.Decode(encodedUID)
|
claimUID, err := idcodec.Decode(encodedUID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
realIP := c.GetString(model.CtxKeyRealIPStr)
|
|
||||||
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,12 +145,11 @@ func identityHandler() func(c *gin.Context) any {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
if claimUID != sess.UserID {
|
if claimUID != sess.UserID {
|
||||||
realIP := c.GetString(model.CtxKeyRealIPStr)
|
|
||||||
model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
currentIP := c.GetString(model.CtxKeyRealIPStr)
|
currentIP := c.GetString(model.CtxKeyRealIPStr)
|
||||||
if sess.IP != currentIP {
|
if !singleton.Conf.JWTIPChangeAllowed() && sess.IP != currentIP {
|
||||||
c.Set(model.CtxKeyIsIPMismatch, true)
|
c.Set(model.CtxKeyIsIPMismatch, true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -265,42 +262,37 @@ func fallbackAuthMiddleware(mw *jwt.GinJWTMiddleware) func(c *gin.Context) {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
claims, err := mw.GetClaimsFromJWT(c)
|
claims, err := mw.GetClaimsFromJWT(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch v := claims["exp"].(type) {
|
switch v := claims["exp"].(type) {
|
||||||
case nil:
|
case nil:
|
||||||
|
c.Next()
|
||||||
return
|
return
|
||||||
case float64:
|
case float64:
|
||||||
if int64(v) < mw.TimeFunc().Unix() {
|
if int64(v) < mw.TimeFunc().Unix() {
|
||||||
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case json.Number:
|
case json.Number:
|
||||||
n, err := v.Int64()
|
n, err := v.Int64()
|
||||||
if err != nil {
|
if err != nil || n < mw.TimeFunc().Unix() {
|
||||||
return
|
c.Next()
|
||||||
}
|
|
||||||
if n < mw.TimeFunc().Unix() {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
realIP := c.GetString(model.CtxKeyRealIPStr)
|
|
||||||
|
|
||||||
c.Set("JWT_PAYLOAD", claims)
|
c.Set("JWT_PAYLOAD", claims)
|
||||||
identity := mw.IdentityHandler(c)
|
identity := mw.IdentityHandler(c)
|
||||||
|
|
||||||
if identity != nil {
|
if identity != nil {
|
||||||
|
realIP := c.GetString(model.CtxKeyRealIPStr)
|
||||||
model.UnblockIP(singleton.DB, realIP, model.BlockIDToken)
|
model.UnblockIP(singleton.DB, realIP, model.BlockIDToken)
|
||||||
c.Set(mw.IdentityKey, identity)
|
c.Set(mw.IdentityKey, identity)
|
||||||
} else {
|
|
||||||
isIpMismatch := c.GetBool(model.CtxKeyIsIPMismatch)
|
|
||||||
if !isIpMismatch {
|
|
||||||
waf.ShowBlockPage(c, model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -193,6 +193,61 @@ func TestIdentityHandlerFlagsIPMismatch(t *testing.T) {
|
|||||||
assert.True(t, verify.GetBool(model.CtxKeyIsIPMismatch))
|
assert.True(t, verify.GetBool(model.CtxKeyIsIPMismatch))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIdentityHandlerAllowsIPMismatchWhenConfigured(t *testing.T) {
|
||||||
|
cleanup := setupJWTSessionTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
singleton.Conf.SetJWTIPChangeAllowed(true)
|
||||||
|
|
||||||
|
ctx := newCtxForUser(0, "1.2.3.4", "ua")
|
||||||
|
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
|
||||||
|
claims, err := issueJWTSession(ctx, &user, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
verify := newCtxForUser(0, "9.9.9.9", "ua")
|
||||||
|
verify.Set("JWT_PAYLOAD", jwt.MapClaims{
|
||||||
|
jwtClaimUserID: claims[jwtClaimUserID],
|
||||||
|
jwtClaimKeyID: claims[jwtClaimKeyID],
|
||||||
|
})
|
||||||
|
|
||||||
|
identity := identityHandler()(verify)
|
||||||
|
require.NotNil(t, identity, "IP mismatch must be accepted when allow_jwt_ip_change is enabled")
|
||||||
|
assert.False(t, verify.GetBool(model.CtxKeyIsIPMismatch))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentityHandlerConcurrentIPChangeSetting(t *testing.T) {
|
||||||
|
cleanup := setupJWTSessionTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
ctx := newCtxForUser(0, "1.2.3.4", "ua")
|
||||||
|
user := model.User{Common: model.Common{ID: 100}, TokenVersion: 7}
|
||||||
|
claims, err := issueJWTSession(ctx, &user, 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
stop, done := make(chan struct{}), make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
singleton.Conf.SetJWTIPChangeAllowed(true)
|
||||||
|
singleton.Conf.SetJWTIPChangeAllowed(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
defer func() {
|
||||||
|
close(stop)
|
||||||
|
<-done
|
||||||
|
}()
|
||||||
|
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
verify := newCtxForUser(0, "1.2.3.4", "ua")
|
||||||
|
verify.Set("JWT_PAYLOAD", jwt.MapClaims(claims))
|
||||||
|
require.NotNil(t, identityHandler()(verify), "matching IP must pass during setting updates")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIdentityHandlerRejectsUnknownKeyID(t *testing.T) {
|
func TestIdentityHandlerRejectsUnknownKeyID(t *testing.T) {
|
||||||
cleanup := setupJWTSessionTest(t)
|
cleanup := setupJWTSessionTest(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ func setupMCPTest(t *testing.T) (func(), uint64) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, db.AutoMigrate(&model.User{}, &model.APIToken{}, &model.MCPAuditLog{}, &model.Server{}, &model.WAF{}))
|
require.NoError(t, db.AutoMigrate(&model.User{}, &model.APIToken{}, &model.MCPAuditLog{}, &model.Server{}, &model.WAF{}))
|
||||||
singleton.DB = db
|
singleton.DB = db
|
||||||
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{JWTTimeout: 1}}
|
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{
|
||||||
|
JWTTimeout: 1,
|
||||||
|
ConfigDashboard: model.ConfigDashboard{
|
||||||
|
DashboardHost: "example.com",
|
||||||
|
},
|
||||||
|
}}
|
||||||
singleton.Conf.SetMCPEnabled(true)
|
singleton.Conf.SetMCPEnabled(true)
|
||||||
|
|
||||||
user := model.User{Common: model.Common{ID: 100}, Username: "alice", Role: model.RoleMember}
|
user := model.User{Common: model.Common{ID: 100}, Username: "alice", Role: model.RoleMember}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -91,6 +92,37 @@ var (
|
|||||||
transferSecretVal string
|
transferSecretVal string
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var errMCPTransferHostNotConfigured = errors.New("MCP transfer URL host is not an operator-declared dashboard host")
|
||||||
|
|
||||||
|
// mcpTransferURLBase never reflects an untrusted request Host into a bearer
|
||||||
|
// transfer URL. DashboardHost is the canonical public authority; deployments
|
||||||
|
// that intentionally serve multiple names must declare them as reserved hosts.
|
||||||
|
func mcpTransferURLBase(c *gin.Context) (string, error) {
|
||||||
|
if c == nil || c.Request == nil || singleton.Conf == nil {
|
||||||
|
return "", errMCPTransferHostNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
requestHost := strings.TrimSpace(c.Request.Host)
|
||||||
|
host := strings.TrimSpace(singleton.Conf.DashboardHost)
|
||||||
|
if host == "" {
|
||||||
|
if !singleton.IsReservedDashboardHost(requestHost) {
|
||||||
|
return "", errMCPTransferHostNotConfigured
|
||||||
|
}
|
||||||
|
host = requestHost
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := url.Parse("//" + host)
|
||||||
|
if err != nil || parsed.Host == "" || parsed.Host != host || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||||||
|
return "", errMCPTransferHostNotConfigured
|
||||||
|
}
|
||||||
|
|
||||||
|
scheme := "https"
|
||||||
|
if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
|
||||||
|
scheme = "http"
|
||||||
|
}
|
||||||
|
return scheme + "://" + host, nil
|
||||||
|
}
|
||||||
|
|
||||||
// transferHMACSecret 返回进程内随机生成的 HMAC key。
|
// transferHMACSecret 返回进程内随机生成的 HMAC key。
|
||||||
// 这是有意设计:transferEntries 本身也只活在内存 sync.Map 里,dashboard
|
// 这是有意设计:transferEntries 本身也只活在内存 sync.Map 里,dashboard
|
||||||
// 重启等价于全部 token 失效;让 secret 也随进程随机,可以避免“secret 来自
|
// 重启等价于全部 token 失效;让 secret 也随进程随机,可以避免“secret 来自
|
||||||
@@ -315,6 +347,10 @@ func mintTransferTool(c *gin.Context, serverID uint64, path string, ttlSeconds i
|
|||||||
if err := validateTransferPath(path); err != nil {
|
if err := validateTransferPath(path); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
baseURL, err := mcpTransferURLBase(c)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
ttl := time.Duration(ttlSeconds) * time.Second
|
ttl := time.Duration(ttlSeconds) * time.Second
|
||||||
if ttl <= 0 {
|
if ttl <= 0 {
|
||||||
ttl = transferTokenTTLDefault
|
ttl = transferTokenTTLDefault
|
||||||
@@ -348,14 +384,9 @@ func mintTransferTool(c *gin.Context, serverID uint64, path string, ttlSeconds i
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
scheme := "https"
|
transferURL := fmt.Sprintf("%s/mcp/%s/%s", baseURL, dir, t)
|
||||||
if c.Request.TLS == nil && c.Request.Header.Get("X-Forwarded-Proto") != "https" {
|
|
||||||
scheme = "http"
|
|
||||||
}
|
|
||||||
host := c.Request.Host
|
|
||||||
url := fmt.Sprintf("%s://%s/mcp/%s/%s", scheme, host, dir, t)
|
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"url": url,
|
"url": transferURL,
|
||||||
"method": map[transferDirection]string{transferDirDownload: "GET", transferDirUpload: "POST"}[dir],
|
"method": map[transferDirection]string{transferDirDownload: "GET", transferDirUpload: "POST"}[dir],
|
||||||
"expires_at": entry.ExpiresAt,
|
"expires_at": entry.ExpiresAt,
|
||||||
}, nil
|
}, nil
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMCPTransferURLPinsUntrustedRequestHostToDashboardHost(t *testing.T) {
|
||||||
|
cleanup, uid := setupMCPTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
PurgeTransferEntries()
|
||||||
|
t.Cleanup(func() { PurgeTransferEntries() })
|
||||||
|
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
||||||
|
|
||||||
|
c, _ := mcpCallCtx(t, tok, uid, nil)
|
||||||
|
c.Request.Host = "attacker.example"
|
||||||
|
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
|
||||||
|
out, err := mintTransferTool(c, 7, "/srv/file", 60, transferDirDownload, transferEntry{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := out.(map[string]any)
|
||||||
|
require.Contains(t, result["url"], "https://example.com/mcp/download/")
|
||||||
|
require.NotContains(t, result["url"], "attacker.example")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPTransferURLRejectsUntrustedHostWithoutCanonicalHost(t *testing.T) {
|
||||||
|
cleanup, uid := setupMCPTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
PurgeTransferEntries()
|
||||||
|
t.Cleanup(func() { PurgeTransferEntries() })
|
||||||
|
tok, _ := mkToken(t, uid, []string{model.ScopeServerRead}, nil)
|
||||||
|
singleton.Conf.DashboardHost = ""
|
||||||
|
singleton.Conf.InstallHost = ""
|
||||||
|
singleton.Conf.ListenHost = ""
|
||||||
|
singleton.Conf.ReservedHosts = ""
|
||||||
|
|
||||||
|
c, _ := mcpCallCtx(t, tok, uid, nil)
|
||||||
|
c.Request = c.Request.Clone(c.Request.Context())
|
||||||
|
c.Request.Method = http.MethodPost
|
||||||
|
c.Request.Host = "attacker.example"
|
||||||
|
|
||||||
|
_, err := mintTransferTool(c, 7, "/srv/file", 60, transferDirDownload, transferEntry{})
|
||||||
|
require.ErrorIs(t, err, errMCPTransferHostNotConfigured)
|
||||||
|
entries := 0
|
||||||
|
transferEntries.Range(func(_, _ any) bool {
|
||||||
|
entries++
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
require.Zero(t, entries, "host validation must happen before a bearer transfer token is minted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPTransferURLAllowsOperatorReservedHost(t *testing.T) {
|
||||||
|
cleanup, _ := setupMCPTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
singleton.Conf.DashboardHost = ""
|
||||||
|
singleton.Conf.ReservedHosts = "mcp.example.com"
|
||||||
|
|
||||||
|
c, _ := mcpCallCtx(t, nil, 0, nil)
|
||||||
|
c.Request.Host = "mcp.example.com"
|
||||||
|
c.Request.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
|
||||||
|
base, err := mcpTransferURLBase(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "https://mcp.example.com", base)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCPTransferURLRejectsMalformedConfiguredHost(t *testing.T) {
|
||||||
|
cleanup, _ := setupMCPTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
singleton.Conf.DashboardHost = "https://example.com/path"
|
||||||
|
|
||||||
|
c, _ := mcpCallCtx(t, nil, 0, nil)
|
||||||
|
_, err := mcpTransferURLBase(c)
|
||||||
|
require.ErrorIs(t, err, errMCPTransferHostNotConfigured)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ import (
|
|||||||
"github.com/nezhahq/nezha/service/singleton"
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const notificationBatchDeleteMaxBodyBytes = 1 << 20
|
||||||
|
|
||||||
// List notification
|
// List notification
|
||||||
// @Summary List notification
|
// @Summary List notification
|
||||||
// @Security BearerAuth
|
// @Security BearerAuth
|
||||||
@@ -132,7 +135,6 @@ func updateNotification(c *gin.Context) (any, error) {
|
|||||||
formatMetricUnits := nf.FormatMetricUnits
|
formatMetricUnits := nf.FormatMetricUnits
|
||||||
n.FormatMetricUnits = &formatMetricUnits
|
n.FormatMetricUnits = &formatMetricUnits
|
||||||
|
|
||||||
|
|
||||||
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
|
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
|
||||||
if nf.URL != "" {
|
if nf.URL != "" {
|
||||||
n.URL = nf.URL
|
n.URL = nf.URL
|
||||||
@@ -177,6 +179,9 @@ func updateNotification(c *gin.Context) (any, error) {
|
|||||||
// @Router /batch-delete/notification [post]
|
// @Router /batch-delete/notification [post]
|
||||||
func batchDeleteNotification(c *gin.Context) (any, error) {
|
func batchDeleteNotification(c *gin.Context) (any, error) {
|
||||||
var n []uint64
|
var n []uint64
|
||||||
|
if c.Request != nil && c.Request.Body != nil {
|
||||||
|
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, notificationBatchDeleteMaxBodyBytes)
|
||||||
|
}
|
||||||
if err := c.ShouldBindJSON(&n); err != nil {
|
if err := c.ShouldBindJSON(&n); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBatchDeleteNotificationCapsRequestBody(t *testing.T) {
|
||||||
|
body := append(bytes.Repeat([]byte{' '}, notificationBatchDeleteMaxBodyBytes+1), '[', ']')
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/batch-delete/notification", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(w)
|
||||||
|
c.Request = req
|
||||||
|
|
||||||
|
_, err := batchDeleteNotification(c)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "request body too large") {
|
||||||
|
t.Fatalf("oversized request body was not rejected by MaxBytesReader: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Get server online history
|
||||||
|
// @Summary Get server online history
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Schemes
|
||||||
|
// @Description Get online periods and aggregated uptime statistics of a specific server
|
||||||
|
// @Tags common
|
||||||
|
// @Param id path uint true "Server ID"
|
||||||
|
// @Param days query int false "Query window in days (default: configured online_stats_days, clamped to it)"
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} model.CommonResponse[model.OnlineHistoryResponse]
|
||||||
|
// @Router /server/{id}/online-history [get]
|
||||||
|
func getServerOnlineHistory(c *gin.Context) (*model.OnlineHistoryResponse, error) {
|
||||||
|
idStr := c.Param("id")
|
||||||
|
serverID, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
server, ok := singleton.ServerShared.Get(serverID)
|
||||||
|
if !ok || server == nil {
|
||||||
|
return nil, singleton.Localizer.ErrorT("server not found")
|
||||||
|
}
|
||||||
|
if !userCanViewServer(c, server) {
|
||||||
|
return nil, singleton.Localizer.ErrorT("unauthorized")
|
||||||
|
}
|
||||||
|
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
|
||||||
|
|
||||||
|
maxDays := singleton.Conf.OnlineHistoryRetentionDays()
|
||||||
|
if maxDays < model.MinOnlineStatsDays || maxDays > model.MaxOnlineStatsDays {
|
||||||
|
maxDays = model.DefaultOnlineStatsDays
|
||||||
|
}
|
||||||
|
days := maxDays
|
||||||
|
if daysStr := c.Query("days"); daysStr != "" {
|
||||||
|
d, err := strconv.Atoi(daysStr)
|
||||||
|
if err != nil || d < 1 {
|
||||||
|
return nil, singleton.Localizer.ErrorT("invalid days parameter")
|
||||||
|
}
|
||||||
|
// 查询窗口收敛到后台设置的统计范围内
|
||||||
|
if d > maxDays {
|
||||||
|
d = maxDays
|
||||||
|
}
|
||||||
|
days = d
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isMember && days > 1 {
|
||||||
|
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
windowEnd := now
|
||||||
|
windowStart := model.OnlineHistoryWindowStart(now, days, singleton.Loc)
|
||||||
|
|
||||||
|
sessions, err := singleton.OnlineHistoryShared.Sessions(serverID, windowStart, windowEnd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, newGormError("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
periods := model.ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||||
|
daily := model.DailyOnlineBreakdown(periods, windowStart, windowEnd, singleton.Loc)
|
||||||
|
// 总时长取各日之和,与 daily[] 严格对账(每日累计原始时长后统一取整,
|
||||||
|
// 口径见 DailyOnlineBreakdown)
|
||||||
|
var totalSeconds uint64
|
||||||
|
for _, d := range daily {
|
||||||
|
totalSeconds += d.OnlineSeconds
|
||||||
|
}
|
||||||
|
windowSeconds := uint64(windowEnd.Sub(windowStart).Seconds())
|
||||||
|
onlineRate := 0.0
|
||||||
|
if windowSeconds > 0 {
|
||||||
|
onlineRate = float64(totalSeconds) / float64(windowSeconds) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
runtime := server.RuntimeSnapshot()
|
||||||
|
currentlyOnline := !runtime.LastActive.IsZero() && now.Sub(runtime.LastActive) <= model.OnlineActiveWindow
|
||||||
|
var lastActive *time.Time
|
||||||
|
if !runtime.LastActive.IsZero() {
|
||||||
|
lastActiveUTC := runtime.LastActive.UTC()
|
||||||
|
lastActive = &lastActiveUTC
|
||||||
|
}
|
||||||
|
|
||||||
|
// 响应统一以 UTC 序列化,避免窗口时间与库中读回的会话时间混用
|
||||||
|
// 本地偏移与 Z 两种写法(时段为值拷贝,不影响调用方)
|
||||||
|
utcPeriods := make([]model.OnlinePeriod, len(periods))
|
||||||
|
for i, p := range periods {
|
||||||
|
utcPeriods[i] = model.OnlinePeriod{Start: p.Start.UTC(), End: p.End.UTC(), Ongoing: p.Ongoing}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.OnlineHistoryResponse{
|
||||||
|
ServerID: serverID,
|
||||||
|
ServerName: server.Name,
|
||||||
|
Days: days,
|
||||||
|
MaxDays: maxDays,
|
||||||
|
Timezone: singleton.Loc.String(),
|
||||||
|
TzOffset: tzOffsetSeconds(now, singleton.Loc),
|
||||||
|
WindowStart: windowStart.UTC(),
|
||||||
|
WindowEnd: windowEnd.UTC(),
|
||||||
|
TotalOnlineSeconds: totalSeconds,
|
||||||
|
OnlineRate: onlineRate,
|
||||||
|
CurrentlyOnline: currentlyOnline,
|
||||||
|
LastActive: lastActive,
|
||||||
|
Periods: utcPeriods,
|
||||||
|
Daily: daily,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tzOffsetSeconds 返回指定时区在给定时刻相对 UTC 的偏移秒数。
|
||||||
|
func tzOffsetSeconds(at time.Time, loc *time.Location) int {
|
||||||
|
_, offset := at.In(loc).Zone()
|
||||||
|
return offset
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/pkg/i18n"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupOnlineHistoryControllerTest(t *testing.T) (visibleServer, hiddenServer *model.Server) {
|
||||||
|
t.Helper()
|
||||||
|
originalDB := singleton.DB
|
||||||
|
originalLocalizer := singleton.Localizer
|
||||||
|
originalServer := singleton.ServerShared
|
||||||
|
originalConf := singleton.Conf
|
||||||
|
originalLoc := singleton.Loc
|
||||||
|
if singleton.Localizer == nil {
|
||||||
|
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Server{}, &model.OnlineSession{}))
|
||||||
|
|
||||||
|
singleton.DB = db
|
||||||
|
singleton.ServerShared = singleton.NewEmptyServerClassForTest()
|
||||||
|
singleton.Conf = &singleton.ConfigClass{Config: &model.Config{}}
|
||||||
|
singleton.Conf.OnlineStatsDays = 30
|
||||||
|
singleton.Loc = time.FixedZone("TEST", 8*3600)
|
||||||
|
|
||||||
|
visibleServer = &model.Server{
|
||||||
|
Common: model.Common{ID: 1},
|
||||||
|
Name: "visible",
|
||||||
|
UUID: "online-history-visible",
|
||||||
|
LastActive: time.Now(),
|
||||||
|
}
|
||||||
|
hiddenServer = &model.Server{
|
||||||
|
Common: model.Common{ID: 2},
|
||||||
|
Name: "hidden",
|
||||||
|
UUID: "online-history-hidden",
|
||||||
|
HideForGuest: true,
|
||||||
|
}
|
||||||
|
singleton.ServerShared.InsertForTest(visibleServer)
|
||||||
|
singleton.ServerShared.InsertForTest(hiddenServer)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
singleton.DB = originalDB
|
||||||
|
singleton.Localizer = originalLocalizer
|
||||||
|
singleton.ServerShared = originalServer
|
||||||
|
singleton.Conf = originalConf
|
||||||
|
singleton.Loc = originalLoc
|
||||||
|
})
|
||||||
|
return visibleServer, hiddenServer
|
||||||
|
}
|
||||||
|
|
||||||
|
func onlineHistoryCtx(t *testing.T, serverID string, query string) *gin.Context {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/server/"+serverID+"/online-history?"+query, nil)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: serverID}}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func onlineHistoryCtxAsMember(t *testing.T, serverID string, query string) *gin.Context {
|
||||||
|
t.Helper()
|
||||||
|
c := onlineHistoryCtx(t, serverID, query)
|
||||||
|
c.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 100}, Role: model.RoleMember})
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedOnlineSessions(t *testing.T, serverID uint64, now time.Time) []model.OnlineSession {
|
||||||
|
t.Helper()
|
||||||
|
now = now.Truncate(time.Second)
|
||||||
|
sessions := []model.OnlineSession{
|
||||||
|
{ServerID: serverID, StartedAt: now.Add(-48 * time.Hour), EndedAt: now.Add(-47 * time.Hour), LastActiveAt: now.Add(-47 * time.Hour)},
|
||||||
|
{ServerID: serverID, StartedAt: now.Add(-25 * time.Hour), EndedAt: now.Add(-23*time.Hour - 30*time.Minute), LastActiveAt: now.Add(-23 * time.Hour)},
|
||||||
|
{ServerID: serverID, StartedAt: now.Add(-10 * time.Minute), Ongoing: true, LastActiveAt: now},
|
||||||
|
// 超出默认 7 天窗口、但在 30 天窗口内的会话
|
||||||
|
{ServerID: serverID, StartedAt: now.Add(-20 * time.Hour * 24), EndedAt: now.Add(-20*time.Hour*24 + time.Hour), LastActiveAt: now.Add(-20 * time.Hour * 24)},
|
||||||
|
}
|
||||||
|
for i := range sessions {
|
||||||
|
require.NoError(t, singleton.DB.Create(&sessions[i]).Error)
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServerOnlineHistoryDefaultsToConfiguredWindow(t *testing.T) {
|
||||||
|
visible, _ := setupOnlineHistoryControllerTest(t)
|
||||||
|
now := time.Now()
|
||||||
|
seedOnlineSessions(t, visible.ID, now)
|
||||||
|
|
||||||
|
// 不带 days 参数:使用后台设置的统计范围(30 天)
|
||||||
|
c := onlineHistoryCtxAsMember(t, "1", "")
|
||||||
|
resp, err := getServerOnlineHistory(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 30, resp.Days)
|
||||||
|
require.Equal(t, 30, resp.MaxDays)
|
||||||
|
// 4 个会话全部落在 30 天窗口内
|
||||||
|
require.Len(t, resp.Periods, 4)
|
||||||
|
require.True(t, resp.CurrentlyOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServerOnlineHistoryDefaultIsSevenDays(t *testing.T) {
|
||||||
|
visible, _ := setupOnlineHistoryControllerTest(t)
|
||||||
|
now := time.Now()
|
||||||
|
seedOnlineSessions(t, visible.ID, now)
|
||||||
|
|
||||||
|
// 出厂默认统计范围为 7 天
|
||||||
|
singleton.Conf.OnlineStatsDays = model.DefaultOnlineStatsDays
|
||||||
|
c := onlineHistoryCtxAsMember(t, "1", "")
|
||||||
|
resp, err := getServerOnlineHistory(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 7, resp.Days)
|
||||||
|
// 20 天前的会话不进入 7 天窗口
|
||||||
|
require.Len(t, resp.Periods, 3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServerOnlineHistoryAggregatesAndClampsWindow(t *testing.T) {
|
||||||
|
visible, _ := setupOnlineHistoryControllerTest(t)
|
||||||
|
now := time.Now()
|
||||||
|
seedOnlineSessions(t, visible.ID, now)
|
||||||
|
|
||||||
|
c := onlineHistoryCtxAsMember(t, "1", "days=9999")
|
||||||
|
resp, err := getServerOnlineHistory(c)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// 请求超出后台设置窗口时收敛到设置值
|
||||||
|
require.Equal(t, 30, resp.Days)
|
||||||
|
|
||||||
|
// 聚合正确性:三个已结束/进行中会话都计入,进行中会话按当前时间截断
|
||||||
|
var total uint64
|
||||||
|
for _, p := range resp.Periods {
|
||||||
|
total += uint64(p.End.Sub(p.Start).Seconds())
|
||||||
|
}
|
||||||
|
require.Equal(t, resp.TotalOnlineSeconds, total)
|
||||||
|
require.GreaterOrEqual(t, resp.TotalOnlineSeconds, uint64(3600+5400+599))
|
||||||
|
|
||||||
|
// 每日拆分合计等于总时长
|
||||||
|
var dailySum uint64
|
||||||
|
for _, d := range resp.Daily {
|
||||||
|
dailySum += d.OnlineSeconds
|
||||||
|
}
|
||||||
|
require.Equal(t, resp.TotalOnlineSeconds, dailySum)
|
||||||
|
|
||||||
|
// 在线率按窗口秒数计算
|
||||||
|
windowSeconds := resp.WindowEnd.Sub(resp.WindowStart).Seconds()
|
||||||
|
require.InDelta(t, float64(resp.TotalOnlineSeconds)/windowSeconds*100, resp.OnlineRate, 0.001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServerOnlineHistoryInvalidDays(t *testing.T) {
|
||||||
|
setupOnlineHistoryControllerTest(t)
|
||||||
|
for _, q := range []string{"days=abc", "days=0", "days=-1"} {
|
||||||
|
c := onlineHistoryCtxAsMember(t, "1", q)
|
||||||
|
_, err := getServerOnlineHistory(c)
|
||||||
|
require.EqualError(t, err, singleton.Localizer.ErrorT("invalid days parameter").Error(), "query %q should be rejected", q)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServerOnlineHistoryUnknownServer(t *testing.T) {
|
||||||
|
setupOnlineHistoryControllerTest(t)
|
||||||
|
c := onlineHistoryCtx(t, "424242", "")
|
||||||
|
_, err := getServerOnlineHistory(c)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServerOnlineHistoryGuestLimitedToOneDay(t *testing.T) {
|
||||||
|
visible, _ := setupOnlineHistoryControllerTest(t)
|
||||||
|
now := time.Now()
|
||||||
|
seedOnlineSessions(t, visible.ID, now)
|
||||||
|
|
||||||
|
// 游客不带 days 参数时默认为配置窗口(>1 天),与 metrics 的游客语义一致被拒绝
|
||||||
|
_, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", ""))
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// 游客显式请求多天被拒绝,1 天可用
|
||||||
|
_, err = getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=3"))
|
||||||
|
require.Error(t, err)
|
||||||
|
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, resp.Days)
|
||||||
|
// 与 1 天窗口相交的两个会话:一个被裁剪到窗口起点,一个进行中
|
||||||
|
require.Len(t, resp.Periods, 2)
|
||||||
|
|
||||||
|
// 登录用户不受游客窗口限制
|
||||||
|
resp, err = getServerOnlineHistory(onlineHistoryCtxAsMember(t, "1", "days=30"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 30, resp.Days)
|
||||||
|
require.Len(t, resp.Periods, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetServerOnlineHistoryPermission(t *testing.T) {
|
||||||
|
visible, _ := setupOnlineHistoryControllerTest(t)
|
||||||
|
|
||||||
|
// 游客可以查看未隐藏服务器(窗口受限于 1 天)
|
||||||
|
guest := onlineHistoryCtx(t, "1", "days=1")
|
||||||
|
resp, err := getServerOnlineHistory(guest)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, visible.ID, resp.ServerID)
|
||||||
|
|
||||||
|
// 游客不能查看 HideForGuest 服务器
|
||||||
|
guestHidden := onlineHistoryCtx(t, "2", "days=1")
|
||||||
|
_, err = getServerOnlineHistory(guestHidden)
|
||||||
|
require.EqualError(t, err, singleton.Localizer.ErrorT("unauthorized").Error())
|
||||||
|
|
||||||
|
// 成员不能查看他人隐藏服务器
|
||||||
|
member := onlineHistoryCtx(t, "2", "days=7")
|
||||||
|
member.Set(model.CtxKeyAuthorizedUser, &model.User{Common: model.Common{ID: 100}, Role: model.RoleMember})
|
||||||
|
_, err = getServerOnlineHistory(member)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func patchOnlineStatsDays(t *testing.T, days any) error {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
body, err := json.Marshal(map[string]any{"user_template": "user-dist", "online_stats_days": days})
|
||||||
|
require.NoError(t, err)
|
||||||
|
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPatch, "/api/v1/setting", bytes.NewReader(body))
|
||||||
|
c.Request.Header.Set("Content-Type", "application/json")
|
||||||
|
_, err = updateConfig(c)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigOnlineStatsDaysRange(t *testing.T) {
|
||||||
|
originalLocalizer := singleton.Localizer
|
||||||
|
if singleton.Localizer == nil {
|
||||||
|
singleton.Localizer = i18n.NewLocalizer("en_US", "nezha", "translations", i18n.Translations)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { singleton.Localizer = originalLocalizer })
|
||||||
|
|
||||||
|
templates := []model.FrontendTemplate{{Path: "user-dist", IsAdmin: false}}
|
||||||
|
originalFrontendTemplates := singleton.FrontendTemplates
|
||||||
|
singleton.FrontendTemplates = templates
|
||||||
|
t.Cleanup(func() { singleton.FrontendTemplates = originalFrontendTemplates })
|
||||||
|
conf := &singleton.ConfigClass{Config: &model.Config{}}
|
||||||
|
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
require.NoError(t, conf.Read(configPath, templates))
|
||||||
|
originalConf := singleton.Conf
|
||||||
|
singleton.Conf = conf
|
||||||
|
t.Cleanup(func() { singleton.Conf = originalConf })
|
||||||
|
|
||||||
|
require.Equal(t, model.DefaultOnlineStatsDays, conf.OnlineStatsDays)
|
||||||
|
|
||||||
|
// 合法边界值
|
||||||
|
for _, d := range []int{model.MinOnlineStatsDays, 30, model.MaxOnlineStatsDays} {
|
||||||
|
require.NoError(t, patchOnlineStatsDays(t, d), "days=%d should be accepted", d)
|
||||||
|
require.Equal(t, d, conf.OnlineStatsDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 越界值整体拒绝且不修改现值
|
||||||
|
for _, d := range []int{6, model.MaxOnlineStatsDays + 1, 0, -5} {
|
||||||
|
require.Error(t, patchOnlineStatsDays(t, d), "days=%d should be rejected", d)
|
||||||
|
require.Equal(t, model.MaxOnlineStatsDays, conf.OnlineStatsDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 非法值必须整体拒绝:校验先于任何对 Conf 的写入完成,其他字段不得
|
||||||
|
// 半提交(评审复现场景:错误返回后 SiteName 不能被请求内容覆盖)
|
||||||
|
conf.SiteName = "existing-site"
|
||||||
|
require.Error(t, patchOnlineStatsDays(t, 6))
|
||||||
|
require.Equal(t, "existing-site", conf.SiteName)
|
||||||
|
conf.SiteName = ""
|
||||||
|
|
||||||
|
// 字段缺省时保留现值(部分 PATCH 语义)
|
||||||
|
require.NoError(t, patchOnlineStatsDays(t, nil))
|
||||||
|
require.Equal(t, model.MaxOnlineStatsDays, conf.OnlineStatsDays)
|
||||||
|
|
||||||
|
// 重启持久化断言:updateConfig 显式 Save 后,重新从磁盘读取的配置
|
||||||
|
// 必须带上刚写入的值(持久化不再依赖 MCP 过渡块的副作用)
|
||||||
|
reloaded := &singleton.ConfigClass{Config: &model.Config{}}
|
||||||
|
require.NoError(t, reloaded.Read(configPath, templates))
|
||||||
|
require.Equal(t, model.MaxOnlineStatsDays, reloaded.OnlineStatsDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingFormOnlineStatsDaysDecode(t *testing.T) {
|
||||||
|
var absent model.SettingForm
|
||||||
|
require.NoError(t, json.NewDecoder(bytes.NewReader([]byte(`{"site_name":"x"}`))).Decode(&absent))
|
||||||
|
require.Nil(t, absent.OnlineStatsDays, "omitted key must decode to nil")
|
||||||
|
|
||||||
|
var explicit model.SettingForm
|
||||||
|
require.NoError(t, json.NewDecoder(bytes.NewReader([]byte(`{"online_stats_days":3650}`))).Decode(&explicit))
|
||||||
|
require.NotNil(t, explicit.OnlineStatsDays)
|
||||||
|
require.Equal(t, 3650, *explicit.OnlineStatsDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryReadsPendingCloseBoundary(t *testing.T) {
|
||||||
|
setupOnlineHistoryControllerTest(t)
|
||||||
|
previous := singleton.OnlineHistoryShared
|
||||||
|
t.Cleanup(func() { singleton.OnlineHistoryShared = previous })
|
||||||
|
r := singleton.NewOnlineHistoryRecorder()
|
||||||
|
singleton.OnlineHistoryShared = r
|
||||||
|
start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
|
||||||
|
r.RecordOnline(1, start)
|
||||||
|
require.NoError(t, singleton.DB.Exec(`CREATE TRIGGER reject_close BEFORE UPDATE ON online_sessions WHEN NEW.ongoing = 0 BEGIN SELECT RAISE(ABORT, 'close failure'); END`).Error)
|
||||||
|
r.RecordOffline(1, start.Add(time.Minute))
|
||||||
|
r.RecordOnline(1, start.Add(30*time.Minute))
|
||||||
|
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Periods, 2)
|
||||||
|
require.False(t, resp.Periods[0].Ongoing)
|
||||||
|
require.True(t, resp.Periods[0].End.Equal(start.Add(time.Minute)))
|
||||||
|
require.Less(t, resp.TotalOnlineSeconds, uint64(1900))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryQueryWithNonUTCSession(t *testing.T) {
|
||||||
|
setupOnlineHistoryControllerTest(t)
|
||||||
|
now := time.Now()
|
||||||
|
offset := time.FixedZone("UTC+12", 12*3600)
|
||||||
|
session := model.OnlineSession{ServerID: 1, StartedAt: now.Add(-time.Hour).In(offset), LastActiveAt: now.Add(-time.Minute).In(offset), Ongoing: true}
|
||||||
|
require.NoError(t, singleton.DB.Create(&session).Error)
|
||||||
|
resp, err := getServerOnlineHistory(onlineHistoryCtx(t, "1", "days=1"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Periods, 1)
|
||||||
|
require.True(t, resp.Periods[0].Start.Equal(session.StartedAt))
|
||||||
|
require.InDelta(t, 3600, resp.TotalOnlineSeconds, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdateConfigFailedSavePreservesRetention(t *testing.T) {
|
||||||
|
server, _ := setupOnlineHistoryControllerTest(t)
|
||||||
|
require.NoError(t, singleton.DB.Create(server).Error)
|
||||||
|
templates := []model.FrontendTemplate{{Path: "user-dist"}}
|
||||||
|
previous := singleton.FrontendTemplates
|
||||||
|
singleton.FrontendTemplates = templates
|
||||||
|
t.Cleanup(func() { singleton.FrontendTemplates = previous })
|
||||||
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||||
|
require.NoError(t, singleton.Conf.Read(path, templates))
|
||||||
|
singleton.Conf.OnlineStatsDays = 30
|
||||||
|
require.NoError(t, singleton.Conf.Save())
|
||||||
|
// Replacing the destination with a directory deterministically fails Save even as root.
|
||||||
|
require.NoError(t, os.Remove(path))
|
||||||
|
require.NoError(t, os.Mkdir(path, 0700))
|
||||||
|
now := time.Now().UTC()
|
||||||
|
old := model.OnlineSession{ServerID: 1, StartedAt: now.AddDate(0, 0, -15), EndedAt: now.AddDate(0, 0, -14)}
|
||||||
|
require.NoError(t, singleton.DB.Create(&old).Error)
|
||||||
|
require.Error(t, patchOnlineStatsDays(t, 7))
|
||||||
|
require.Equal(t, 30, singleton.Conf.OnlineStatsDays)
|
||||||
|
singleton.PruneOnlineSessions()
|
||||||
|
var retained model.OnlineSession
|
||||||
|
require.NoError(t, singleton.DB.First(&retained, old.ID).Error)
|
||||||
|
require.Equal(t, old.ID, retained.ID)
|
||||||
|
}
|
||||||
@@ -42,12 +42,15 @@ func serveAgentBinary(c *gin.Context) {
|
|||||||
// In a real scenario, you might want to cache this or use a specific version
|
// In a real scenario, you might want to cache this or use a specific version
|
||||||
zipUrl := fmt.Sprintf("https://github.com/%s/releases/latest/download/nezha-agent_%s_%s.zip", repo, osType, arch)
|
zipUrl := fmt.Sprintf("https://github.com/%s/releases/latest/download/nezha-agent_%s_%s.zip", repo, osType, arch)
|
||||||
|
|
||||||
|
// #nosec G107
|
||||||
resp, err := http.Get(zipUrl)
|
resp, err := http.Get(zipUrl)
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
// Try Gitee if GitHub fails
|
// Try Gitee if GitHub fails
|
||||||
zipUrl = fmt.Sprintf("https://gitee.com/naibahq/agent/releases/latest/download/nezha-agent_%s_%s.zip", osType, arch)
|
zipUrl = fmt.Sprintf("https://gitee.com/naibahq/agent/releases/latest/download/nezha-agent_%s_%s.zip", osType, arch)
|
||||||
|
// #nosec G107
|
||||||
resp, err = http.Get(zipUrl)
|
resp, err = http.Get(zipUrl)
|
||||||
if err != nil || resp.StatusCode != http.StatusOK {
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
|
||||||
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch agent binary from upstream"})
|
c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch agent binary from upstream"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -236,10 +236,9 @@ func getServerConfig(c *gin.Context) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
s, ok := singleton.ServerShared.Get(id)
|
s, ok := singleton.ServerShared.Get(id)
|
||||||
if !ok {
|
// Foreign and unknown IDs deliberately share one response so sequential
|
||||||
return "", nil
|
// server IDs cannot be enumerated across tenants.
|
||||||
}
|
if !ok || !s.HasPermission(c) || !patAllowsServer(c, id) {
|
||||||
if !s.HasPermission(c) {
|
|
||||||
return "", singleton.Localizer.ErrorT("permission denied")
|
return "", singleton.Localizer.ErrorT("permission denied")
|
||||||
}
|
}
|
||||||
if s.GetTaskStream() == nil {
|
if s.GetTaskStream() == nil {
|
||||||
@@ -293,8 +292,9 @@ func setServerConfig(c *gin.Context) (*model.ServerTaskResponse, error) {
|
|||||||
slist := singleton.ServerShared.GetList()
|
slist := singleton.ServerShared.GetList()
|
||||||
servers := make([]*model.Server, 0, len(configForm.Servers))
|
servers := make([]*model.Server, 0, len(configForm.Servers))
|
||||||
for _, sid := range configForm.Servers {
|
for _, sid := range configForm.Servers {
|
||||||
if s, ok := slist[sid]; ok {
|
s, ok := slist[sid]
|
||||||
if !s.HasPermission(c) {
|
// Match getServerConfig: do not reveal whether a denied ID exists.
|
||||||
|
if !ok || !s.HasPermission(c) || !patAllowsServer(c, sid) {
|
||||||
return nil, singleton.Localizer.ErrorT("permission denied")
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
||||||
}
|
}
|
||||||
if s.GetTaskStream() == nil {
|
if s.GetTaskStream() == nil {
|
||||||
@@ -303,7 +303,6 @@ func setServerConfig(c *gin.Context) (*model.ServerTaskResponse, error) {
|
|||||||
}
|
}
|
||||||
servers = append(servers, s)
|
servers = append(servers, s)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
var respMu sync.Mutex
|
var respMu sync.Mutex
|
||||||
@@ -556,3 +555,51 @@ func getServerMetrics(c *gin.Context) (*model.ServerMetricsResponse, error) {
|
|||||||
|
|
||||||
return response, nil
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// BatchLockdownOnlineServers 批量收敛并锁定现有在线 Agent
|
||||||
|
// @Summary Batch lockdown online servers to telemetry only
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Produce json
|
||||||
|
// @Router /batch-lockdown/server [post]
|
||||||
|
func batchLockdownOnlineServers(c *gin.Context) (*model.ServerTaskResponse, error) {
|
||||||
|
var form struct {
|
||||||
|
Servers []uint64 `json:"servers"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&form); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := new(model.ServerTaskResponse)
|
||||||
|
for _, sid := range form.Servers {
|
||||||
|
srv, _ := singleton.ServerShared.Get(sid)
|
||||||
|
if srv == nil || !srv.HasPermission(c) {
|
||||||
|
resp.Offline = append(resp.Offline, sid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if srv.GetTaskStream() == nil {
|
||||||
|
resp.Offline = append(resp.Offline, sid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 下发原子化加固与重启脚本
|
||||||
|
task := &pb.Task{
|
||||||
|
Type: model.TaskTypeCommand,
|
||||||
|
Data: model.SafeDecommissionScript,
|
||||||
|
}
|
||||||
|
if err := srv.SendTask(task); err != nil {
|
||||||
|
if errors.Is(err, model.ErrTaskStreamOffline) {
|
||||||
|
resp.Offline = append(resp.Offline, sid)
|
||||||
|
} else {
|
||||||
|
resp.Failure = append(resp.Failure, sid)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地更新状态并异步持久化落库
|
||||||
|
srv.TelemetryOnly = true
|
||||||
|
singleton.DB.Model(&model.Server{}).Where("id = ?", srv.ID).Update("telemetry_only", true)
|
||||||
|
resp.Success = append(resp.Success, sid)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetServerConfigForeignAndUnknownIDsAreIndistinguishable(t *testing.T) {
|
||||||
|
cleanup, _ := setupMCPTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 200}, Username: "bob", Role: model.RoleMember}).Error)
|
||||||
|
tok, _ := mkToken(t, 200, []string{model.ScopeServerRead}, nil)
|
||||||
|
|
||||||
|
probe := func(id string) error {
|
||||||
|
c, _ := patRequestCtx(t, tok, 200, http.MethodGet, "/api/v1/server/config/"+id, nil)
|
||||||
|
c.Params = gin.Params{{Key: "id", Value: id}}
|
||||||
|
_, err := getServerConfig(c)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
foreignErr := probe("7")
|
||||||
|
unknownErr := probe("999999")
|
||||||
|
require.Error(t, foreignErr)
|
||||||
|
require.Error(t, unknownErr)
|
||||||
|
require.Equal(t, foreignErr.Error(), unknownErr.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSetServerConfigForeignAndUnknownIDsAreIndistinguishable(t *testing.T) {
|
||||||
|
cleanup, _ := setupMCPTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
require.NoError(t, singleton.DB.Create(&model.User{Common: model.Common{ID: 200}, Username: "bob", Role: model.RoleMember}).Error)
|
||||||
|
tok, _ := mkToken(t, 200, []string{model.ScopeServerWrite}, nil)
|
||||||
|
|
||||||
|
probe := func(id uint64) error {
|
||||||
|
c, _ := patRequestCtx(t, tok, 200, http.MethodPost, "/api/v1/server/config", model.ServerConfigForm{
|
||||||
|
Servers: []uint64{id},
|
||||||
|
Config: "{}",
|
||||||
|
})
|
||||||
|
_, err := setServerConfig(c)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
foreignErr := probe(7)
|
||||||
|
unknownErr := probe(999999)
|
||||||
|
require.Error(t, foreignErr)
|
||||||
|
require.Error(t, unknownErr)
|
||||||
|
require.Equal(t, foreignErr.Error(), unknownErr.Error())
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ func listConfig(c *gin.Context) (*model.SettingResponse, error) {
|
|||||||
isAdmin = user.Role.IsAdmin()
|
isAdmin = user.Role.IsAdmin()
|
||||||
}
|
}
|
||||||
|
|
||||||
config := *singleton.Conf
|
config := singleton.Conf.SettingsSnapshot()
|
||||||
config.Language = strings.ReplaceAll(config.Language, "_", "-")
|
config.Language = strings.ReplaceAll(config.Language, "_", "-")
|
||||||
|
|
||||||
conf := model.SettingResponse{
|
conf := model.SettingResponse{
|
||||||
@@ -92,11 +93,20 @@ func updateConfig(c *gin.Context) (any, error) {
|
|||||||
if !userTemplateValid {
|
if !userTemplateValid {
|
||||||
return nil, errors.New("invalid user template")
|
return nil, errors.New("invalid user template")
|
||||||
}
|
}
|
||||||
|
// 全部输入校验必须先于任何对 singleton.Conf 的写入:任一字段非法都应
|
||||||
|
// 整体拒绝,避免"请求以错误收场、先处理的字段却已生效"的半提交状态
|
||||||
|
if sf.OnlineStatsDays != nil &&
|
||||||
|
(*sf.OnlineStatsDays < model.MinOnlineStatsDays || *sf.OnlineStatsDays > model.MaxOnlineStatsDays) {
|
||||||
|
return nil, fmt.Errorf("online_stats_days must be between %d and %d", model.MinOnlineStatsDays, model.MaxOnlineStatsDays)
|
||||||
|
}
|
||||||
|
|
||||||
singleton.Conf.Language = strings.ReplaceAll(sf.Language, "-", "_")
|
singleton.Conf.Language = strings.ReplaceAll(sf.Language, "-", "_")
|
||||||
|
|
||||||
singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification
|
singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification
|
||||||
singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
|
singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
|
||||||
|
if sf.AllowJWTIPChange != nil {
|
||||||
|
singleton.Conf.SetJWTIPChangeAllowed(*sf.AllowJWTIPChange)
|
||||||
|
}
|
||||||
singleton.Conf.Cover = sf.Cover
|
singleton.Conf.Cover = sf.Cover
|
||||||
singleton.Conf.InstallHost = sf.InstallHost
|
singleton.Conf.InstallHost = sf.InstallHost
|
||||||
singleton.Conf.DashboardHost = sf.DashboardHost
|
singleton.Conf.DashboardHost = sf.DashboardHost
|
||||||
@@ -124,20 +134,24 @@ func updateConfig(c *gin.Context) (any, error) {
|
|||||||
singleton.Conf.DomainExpiryNotificationDays = sf.DomainExpiryNotificationDays
|
singleton.Conf.DomainExpiryNotificationDays = sf.DomainExpiryNotificationDays
|
||||||
singleton.Conf.ServerExpiryNotificationDays = sf.ServerExpiryNotificationDays
|
singleton.Conf.ServerExpiryNotificationDays = sf.ServerExpiryNotificationDays
|
||||||
|
|
||||||
|
if err := singleton.Conf.CommitOnlineHistoryRetention(sf.OnlineStatsDays, func() error {
|
||||||
mcpWasEnabled := singleton.Conf.MCPEnabled()
|
mcpWasEnabled := singleton.Conf.MCPEnabled()
|
||||||
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
|
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
|
||||||
|
return applyEnableMCPTransition(
|
||||||
|
|
||||||
if err := applyEnableMCPTransition(
|
|
||||||
mcpWasEnabled, mcpNext,
|
mcpWasEnabled, mcpNext,
|
||||||
singleton.Conf.SetMCPEnabled,
|
singleton.Conf.SetMCPEnabled,
|
||||||
singleton.Conf.Save,
|
singleton.Conf.Save,
|
||||||
fireMCPKillSwitch,
|
fireMCPKillSwitch,
|
||||||
); err != nil {
|
)
|
||||||
|
}); err != nil {
|
||||||
return nil, newGormError("%v", err)
|
return nil, newGormError("%v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// applyEnableMCPTransition already persisted every setting, even when MCP
|
||||||
|
// is unchanged. A second save could fail after irreversible cleanup.
|
||||||
|
|
||||||
singleton.OnUpdateLang(singleton.Conf.Language)
|
singleton.OnUpdateLang(singleton.Conf.Language)
|
||||||
|
singleton.InitTelegramBot()
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +188,12 @@ func fireMCPKillSwitch() {
|
|||||||
// the current config to avoid accidentally tripping the kill switch on
|
// the current config to avoid accidentally tripping the kill switch on
|
||||||
// partial PATCH calls that omit enable_mcp.
|
// partial PATCH calls that omit enable_mcp.
|
||||||
func resolveSettingEnableMCP(formValue *bool, current bool) bool {
|
func resolveSettingEnableMCP(formValue *bool, current bool) bool {
|
||||||
|
return resolveOptionalBool(formValue, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveOptionalBool preserves a setting for partial PATCH requests when its
|
||||||
|
// boolean field is absent, while still allowing an explicit false value.
|
||||||
|
func resolveOptionalBool(formValue *bool, current bool) bool {
|
||||||
if formValue == nil {
|
if formValue == nil {
|
||||||
return current
|
return current
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingForm_AllowJWTIPChangePreservesAbsentAndDecodesExplicitValues(t *testing.T) {
|
||||||
|
var omitted model.SettingForm
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(`{"site_name":"X"}`), &omitted))
|
||||||
|
require.Nil(t, omitted.AllowJWTIPChange)
|
||||||
|
|
||||||
|
var enabled model.SettingForm
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(`{"allow_jwt_ip_change":true}`), &enabled))
|
||||||
|
require.NotNil(t, enabled.AllowJWTIPChange)
|
||||||
|
require.True(t, *enabled.AllowJWTIPChange)
|
||||||
|
|
||||||
|
var disabled model.SettingForm
|
||||||
|
require.NoError(t, json.Unmarshal([]byte(`{"allow_jwt_ip_change":false}`), &disabled))
|
||||||
|
require.NotNil(t, disabled.AllowJWTIPChange)
|
||||||
|
require.False(t, *disabled.AllowJWTIPChange)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"go/ast"
|
||||||
|
"go/parser"
|
||||||
|
"go/token"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The transition helper owns Save (including unchanged MCP settings). A direct
|
||||||
|
// second Save after it can fail after persistence and irreversible revocation.
|
||||||
|
func TestUpdateConfigHasSinglePersistenceOwner(t *testing.T) {
|
||||||
|
file, err := parser.ParseFile(token.NewFileSet(), "setting.go", nil, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
transitions, directSaves := 0, 0
|
||||||
|
found := false
|
||||||
|
for _, decl := range file.Decls {
|
||||||
|
fn, ok := decl.(*ast.FuncDecl)
|
||||||
|
if !ok || fn.Name.Name != "updateConfig" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
ast.Inspect(fn.Body, func(n ast.Node) bool {
|
||||||
|
call, ok := n.(*ast.CallExpr)
|
||||||
|
if !ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if ident, ok := call.Fun.(*ast.Ident); ok && ident.Name == "applyEnableMCPTransition" {
|
||||||
|
transitions++
|
||||||
|
}
|
||||||
|
if selector, ok := call.Fun.(*ast.SelectorExpr); ok && selector.Sel.Name == "Save" {
|
||||||
|
directSaves++
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
require.True(t, found)
|
||||||
|
require.Equal(t, 1, transitions)
|
||||||
|
require.Zero(t, directSaves)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnchangedMCPTransitionStillSavesOnce(t *testing.T) {
|
||||||
|
saves := 0
|
||||||
|
err := applyEnableMCPTransition(true, true, func(bool) {}, func() error { saves++; return nil }, func() { t.Error("unchanged MCP must not revoke streams") })
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, saves)
|
||||||
|
}
|
||||||
@@ -46,32 +46,6 @@ func setAuthUser(c *gin.Context, userID uint64, role model.Role) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTerminalStreamRejectsForeignMember(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
ensureLocalizerForStreamTests(t)
|
|
||||||
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
|
|
||||||
rpc.NezhaHandlerSingleton.CreateStream("alice-terminal", 100, 1)
|
|
||||||
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(func(c *gin.Context) {
|
|
||||||
setAuthUser(c, 200, model.RoleMember) // bob
|
|
||||||
c.Next()
|
|
||||||
})
|
|
||||||
r.GET("/ws/terminal/:id", commonHandler(terminalStream))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/ws/terminal/alice-terminal", nil)
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
success, errMsg := decodeCommonResponseError(t, w.Body.Bytes())
|
|
||||||
assert.False(t, success, "foreign member must not be authorized to attach to alice's terminal")
|
|
||||||
assert.Contains(t, errMsg, "permission denied")
|
|
||||||
|
|
||||||
// And the existing stream must NOT have been torn down by the failed attempt.
|
|
||||||
_, stillExists := rpc.NezhaHandlerSingleton.StreamOwnership("alice-terminal")
|
|
||||||
assert.True(t, stillExists, "rejected attempt must not destroy the legitimate session")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFMStreamRejectsForeignMember(t *testing.T) {
|
func TestFMStreamRejectsForeignMember(t *testing.T) {
|
||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
ensureLocalizerForStreamTests(t)
|
ensureLocalizerForStreamTests(t)
|
||||||
@@ -97,26 +71,6 @@ func TestFMStreamRejectsForeignMember(t *testing.T) {
|
|||||||
assert.True(t, stillExists, "rejected attempt must not destroy the legitimate FM session")
|
assert.True(t, stillExists, "rejected attempt must not destroy the legitimate FM session")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTerminalStreamRejectsUnknownStreamID(t *testing.T) {
|
|
||||||
gin.SetMode(gin.TestMode)
|
|
||||||
ensureLocalizerForStreamTests(t)
|
|
||||||
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
|
|
||||||
|
|
||||||
r := gin.New()
|
|
||||||
r.Use(func(c *gin.Context) {
|
|
||||||
setAuthUser(c, 100, model.RoleMember)
|
|
||||||
c.Next()
|
|
||||||
})
|
|
||||||
r.GET("/ws/terminal/:id", commonHandler(terminalStream))
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
req := httptest.NewRequest(http.MethodGet, "/ws/terminal/nonexistent", nil)
|
|
||||||
r.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
success, _ := decodeCommonResponseError(t, w.Body.Bytes())
|
|
||||||
assert.False(t, success, "unknown stream id must produce an error response")
|
|
||||||
}
|
|
||||||
|
|
||||||
// JWT cookie security: SigningAlgorithm must be pinned to HS256 (defense
|
// JWT cookie security: SigningAlgorithm must be pinned to HS256 (defense
|
||||||
// against future algorithm-confusion regressions in the library) and the
|
// against future algorithm-confusion regressions in the library) and the
|
||||||
// JWT cookie must use SameSite=Lax so cross-site GET navigations don't
|
// JWT cookie must use SameSite=Lax so cross-site GET navigations don't
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/goccy/go-json"
|
||||||
|
"github.com/hashicorp/go-uuid"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/nezhahq/nezha/pkg/websocketx"
|
||||||
|
"github.com/nezhahq/nezha/proto"
|
||||||
|
"github.com/nezhahq/nezha/service/rpc"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Allow the frontend's 512 KiB clipboard payload plus xterm's bracketed-paste
|
||||||
|
// control bytes, while keeping the complete tagged message below the 1 MiB
|
||||||
|
// IO stream relay buffer.
|
||||||
|
const terminalWebSocketInputLimit int64 = 512*1024 + 64
|
||||||
|
|
||||||
|
// Create web ssh terminal
|
||||||
|
// @Summary Create web ssh terminal
|
||||||
|
// @Description Create web ssh terminal
|
||||||
|
// @Tags auth required
|
||||||
|
// @Accept json
|
||||||
|
// @Param terminal body model.TerminalForm true "TerminalForm"
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} model.CreateTerminalResponse
|
||||||
|
// @Router /terminal [post]
|
||||||
|
func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) {
|
||||||
|
prepareAgentcompatCapabilityHeader(c)
|
||||||
|
var createTerminalReq model.TerminalForm
|
||||||
|
if err := c.ShouldBind(&createTerminalReq); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
server, _ := singleton.ServerShared.Get(createTerminalReq.ServerID)
|
||||||
|
if server == nil {
|
||||||
|
return nil, singleton.Localizer.ErrorT("server not found or not connected")
|
||||||
|
}
|
||||||
|
if server.GetTaskStream() == nil {
|
||||||
|
return nil, singleton.Localizer.ErrorT("server not found or not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !server.HasPermission(c) {
|
||||||
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.IsTelemetryOnly() {
|
||||||
|
return nil, singleton.Localizer.ErrorT("terminal is disabled: server is in telemetry-only mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
streamId, err := uuid.GenerateUUID()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup, err := createIOStreamWithAgentcompatCapability(c, streamId, getUid(c), server.ID, rpc.AgentCompatCapabilityTerminal)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalData, err := json.Marshal(&model.TerminalTask{
|
||||||
|
StreamID: streamId,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// A stream is owned by the caller only after this function succeeds.
|
||||||
|
cleanup()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := server.SendTask(&proto.Task{
|
||||||
|
Type: model.TaskTypeTerminalGRPC,
|
||||||
|
Data: string(terminalData),
|
||||||
|
}); err != nil {
|
||||||
|
cleanup()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.CreateTerminalResponse{
|
||||||
|
SessionID: streamId,
|
||||||
|
ServerID: server.ID,
|
||||||
|
ServerName: server.Name,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminalStream web ssh terminal stream
|
||||||
|
// @Summary Terminal stream
|
||||||
|
// @Description Terminal stream
|
||||||
|
// @Tags auth required
|
||||||
|
// @Param id path string true "Stream UUID"
|
||||||
|
// @Success 200 {object} model.CommonResponse[any]
|
||||||
|
// @Router /ws/terminal/{id} [get]
|
||||||
|
func terminalStream(c *gin.Context) (any, error) {
|
||||||
|
streamId := c.Param("id")
|
||||||
|
// GHSA-style fix: io_stream sessions must be reachable only by their creator
|
||||||
|
// (or an admin). Without this, any authenticated user who learns a stream
|
||||||
|
// UUID — via Referer leak, access logs, browser history — can hijack a live
|
||||||
|
// terminal and gain shell access to the target server.
|
||||||
|
if !streamAttachAllowedForRequest(c, streamId) {
|
||||||
|
return nil, singleton.Localizer.ErrorT("permission denied")
|
||||||
|
}
|
||||||
|
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
|
||||||
|
|
||||||
|
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, newWsError("%v", err)
|
||||||
|
}
|
||||||
|
wsConn.SetReadLimit(terminalWebSocketInputLimit)
|
||||||
|
conn := websocketx.NewConn(wsConn)
|
||||||
|
pingTransport := newWebsocketPingTransport(conn, wsConn.Close)
|
||||||
|
stopPing := startWebsocketPingTicker(c.Request.Context(), time.Second*10, pingTransport)
|
||||||
|
|
||||||
|
deregisterPAT := registerPATConnection(c, func() { _ = pingTransport.Close() })
|
||||||
|
defer deregisterPAT()
|
||||||
|
// Join the ping worker before PAT and WebSocket cleanup can close its writer.
|
||||||
|
defer stopPing()
|
||||||
|
|
||||||
|
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
|
||||||
|
return nil, newWsError("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10); err != nil {
|
||||||
|
return nil, newWsError("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, newWsError("")
|
||||||
|
}
|
||||||
@@ -15,25 +15,6 @@ import (
|
|||||||
"github.com/nezhahq/nezha/service/singleton"
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestDefaultCreateTerminalPreservesCapabilityHeaderAndLegacyDispatch(t *testing.T) {
|
|
||||||
// Given
|
|
||||||
handler, _, request := newDefaultCreateFixture(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
||||||
request.Request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed")
|
|
||||||
server, ok := singleton.ServerShared.Get(7)
|
|
||||||
require.True(t, ok)
|
|
||||||
stream := &failingRequestTaskStream{err: errors.New("stop after dispatch")}
|
|
||||||
server.SetTaskStream(stream)
|
|
||||||
|
|
||||||
// When
|
|
||||||
_, err := createTerminal(request)
|
|
||||||
|
|
||||||
// Then
|
|
||||||
require.ErrorIs(t, err, stream.err)
|
|
||||||
require.Equal(t, "malformed", request.Request.Header.Get(agentcompatcontract.IOStreamCapabilityHeader))
|
|
||||||
require.Equal(t, 1, stream.calls())
|
|
||||||
require.Equal(t, 0, handler.StreamCount())
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestDefaultCreateFMPreservesCapabilityHeaderAndLegacyDispatch(t *testing.T) {
|
func TestDefaultCreateFMPreservesCapabilityHeaderAndLegacyDispatch(t *testing.T) {
|
||||||
// Given
|
// Given
|
||||||
handler, _, request := newDefaultCreateFixture(t, "POST", "/file?id=7", nil)
|
handler, _, request := newDefaultCreateFixture(t, "POST", "/file?id=7", nil)
|
||||||
|
|||||||
@@ -59,27 +59,6 @@ func newAuthorizedControllerContext(t *testing.T, method, target string, body an
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCreateTerminalReturnsSendErrorAndReleasesStreamCapacity(t *testing.T) {
|
|
||||||
cleanupFixture, _ := setupMCPTest(t)
|
|
||||||
defer cleanupFixture()
|
|
||||||
originalHandler := rpc.NezhaHandlerSingleton
|
|
||||||
rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler()
|
|
||||||
t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler })
|
|
||||||
|
|
||||||
sendError := errors.New("terminal task send failed")
|
|
||||||
stream := &failingRequestTaskStream{err: sendError}
|
|
||||||
server, ok := singleton.ServerShared.Get(7)
|
|
||||||
require.True(t, ok)
|
|
||||||
server.SetTaskStream(stream)
|
|
||||||
|
|
||||||
request := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
||||||
response, err := createTerminal(request)
|
|
||||||
require.ErrorIs(t, err, sendError)
|
|
||||||
require.Nil(t, response)
|
|
||||||
require.Equal(t, 1, stream.calls())
|
|
||||||
assertStreamCapacityReusable(t, rpc.NezhaHandlerSingleton, 100, 7, "terminal-reused")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCreateFMReturnsSendErrorAndReleasesStreamCapacity(t *testing.T) {
|
func TestCreateFMReturnsSendErrorAndReleasesStreamCapacity(t *testing.T) {
|
||||||
cleanupFixture, _ := setupMCPTest(t)
|
cleanupFixture, _ := setupMCPTest(t)
|
||||||
defer cleanupFixture()
|
defer cleanupFixture()
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
"github.com/nezhahq/nezha/model"
|
|
||||||
"github.com/nezhahq/nezha/service/rpc"
|
"github.com/nezhahq/nezha/service/rpc"
|
||||||
"github.com/nezhahq/nezha/service/singleton"
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
)
|
)
|
||||||
@@ -49,32 +48,8 @@ func setupQuotaTest(t *testing.T) (cleanup func(), successStream *failingRequest
|
|||||||
}, successStream
|
}, successStream
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateTerminalEnforcesPerUserStreamQuota verifies that once a user has
|
// TestCreateFMEnforcesPerUserStreamQuota tests POST /file is blocked once
|
||||||
// reached the per-user stream cap, subsequent createTerminal calls are rejected
|
// the per-user stream cap is reached.
|
||||||
// with ErrTooManyStreamsForUser. This directly tests the GHSA-jg62-j5h6-8mpq
|
|
||||||
// fix at the HTTP handler layer.
|
|
||||||
func TestCreateTerminalEnforcesPerUserStreamQuota(t *testing.T) {
|
|
||||||
cleanup, _ := setupQuotaTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// Fill the per-user quota.
|
|
||||||
for i := 0; i < quotaTestUserCap; i++ {
|
|
||||||
req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
||||||
_, err := createTerminal(req)
|
|
||||||
require.NoError(t, err, "terminal %d must succeed within per-user quota", i+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The (quotaTestUserCap+1)-th call must be rejected.
|
|
||||||
req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
||||||
_, err := createTerminal(req)
|
|
||||||
require.Error(t, err, "createTerminal must return an error when user quota is exhausted")
|
|
||||||
require.True(t, errors.Is(err, rpc.ErrTooManyStreamsForUser),
|
|
||||||
"error must be ErrTooManyStreamsForUser when user quota is exhausted, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreateFMEnforcesPerUserStreamQuota is the FM counterpart of the terminal
|
|
||||||
// quota test: POST /file must also be blocked once the per-user stream cap is
|
|
||||||
// reached.
|
|
||||||
func TestCreateFMEnforcesPerUserStreamQuota(t *testing.T) {
|
func TestCreateFMEnforcesPerUserStreamQuota(t *testing.T) {
|
||||||
cleanup, _ := setupQuotaTest(t)
|
cleanup, _ := setupQuotaTest(t)
|
||||||
defer cleanup()
|
defer cleanup()
|
||||||
@@ -94,32 +69,6 @@ func TestCreateFMEnforcesPerUserStreamQuota(t *testing.T) {
|
|||||||
"error must be ErrTooManyStreamsForUser when user quota is exhausted, got: %v", err)
|
"error must be ErrTooManyStreamsForUser when user quota is exhausted, got: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestCreateTerminalEnforcesPerServerStreamQuota verifies that even when a
|
|
||||||
// single user's quota is not yet reached, createTerminal rejects streams once
|
|
||||||
// the per-server cap is hit. This guards against a distributed attack where
|
|
||||||
// many users flood one server.
|
|
||||||
func TestCreateTerminalEnforcesPerServerStreamQuota(t *testing.T) {
|
|
||||||
cleanup, _ := setupQuotaTest(t)
|
|
||||||
defer cleanup()
|
|
||||||
|
|
||||||
// Pre-fill the per-server quota with dashboard-internal streams
|
|
||||||
// (creatorUserID=0 bypasses the per-user cap so we can reach the server cap
|
|
||||||
// without needing quotaTestServerCap distinct users).
|
|
||||||
for i := 0; i < quotaTestServerCap; i++ {
|
|
||||||
require.NoError(t,
|
|
||||||
rpc.NezhaHandlerSingleton.CreateStream(fmt.Sprintf("server-filler-%d", i), 0, 7),
|
|
||||||
"pre-fill server quota stream %d must succeed", i+1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// User 100 has used 0 of their personal quota; the server is saturated.
|
|
||||||
req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7})
|
|
||||||
_, err := createTerminal(req)
|
|
||||||
require.Error(t, err, "createTerminal must return an error when server quota is exhausted")
|
|
||||||
require.True(t, errors.Is(err, rpc.ErrTooManyStreamsForServer),
|
|
||||||
"error must be ErrTooManyStreamsForServer when server quota is exhausted, got: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestCreateFMEnforcesPerServerStreamQuota is the FM counterpart: POST /file
|
// TestCreateFMEnforcesPerServerStreamQuota is the FM counterpart: POST /file
|
||||||
// must also be blocked once the per-server stream cap is reached.
|
// must also be blocked once the per-server stream cap is reached.
|
||||||
func TestCreateFMEnforcesPerServerStreamQuota(t *testing.T) {
|
func TestCreateFMEnforcesPerServerStreamQuota(t *testing.T) {
|
||||||
|
|||||||
@@ -2,8 +2,27 @@ package controller
|
|||||||
|
|
||||||
import "testing"
|
import "testing"
|
||||||
|
|
||||||
|
type readLimitRecorder struct {
|
||||||
|
limit int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *readLimitRecorder) SetReadLimit(limit int64) {
|
||||||
|
r.limit = limit
|
||||||
|
}
|
||||||
|
|
||||||
func TestTerminalWebSocketInputLimitAllowsBoundedPaste(t *testing.T) {
|
func TestTerminalWebSocketInputLimitAllowsBoundedPaste(t *testing.T) {
|
||||||
if terminalWebSocketInputLimit != 512*1024+64 {
|
if terminalWebSocketInputLimit != 512*1024+64 {
|
||||||
t.Fatalf("terminal WebSocket input limit = %d, want 512 KiB plus control-byte allowance", terminalWebSocketInputLimit)
|
t.Fatalf("terminal WebSocket input limit = %d, want 512 KiB plus control-byte allowance", terminalWebSocketInputLimit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileManagerWebSocketInputLimitMatchesUploadChunk(t *testing.T) {
|
||||||
|
if fileManagerWebSocketInputLimit != 1024*1024 {
|
||||||
|
t.Fatalf("file-manager WebSocket input limit = %d, want official client's 1 MiB upload chunk", fileManagerWebSocketInputLimit)
|
||||||
|
}
|
||||||
|
recorder := new(readLimitRecorder)
|
||||||
|
limitFileManagerWebSocketInput(recorder)
|
||||||
|
if recorder.limit != fileManagerWebSocketInputLimit {
|
||||||
|
t.Fatalf("file-manager WebSocket configured limit = %d, want %d", recorder.limit, fileManagerWebSocketInputLimit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -171,6 +171,12 @@ const transferStreamPingInterval = 30 * time.Second
|
|||||||
// @Success 200 {object} model.ServerTransfer
|
// @Success 200 {object} model.ServerTransfer
|
||||||
// @Router /ws/transfer [get]
|
// @Router /ws/transfer [get]
|
||||||
func transferStream(c *gin.Context) (any, error) {
|
func transferStream(c *gin.Context) (any, error) {
|
||||||
|
subID, ch, err := singleton.ServerTransferShared.Subscribe(getUid(c))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer singleton.ServerTransferShared.Unsubscribe(subID)
|
||||||
|
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, newWsError("%v", err)
|
return nil, newWsError("%v", err)
|
||||||
@@ -180,9 +186,6 @@ func transferStream(c *gin.Context) (any, error) {
|
|||||||
deregisterPAT := registerPATConnection(c, func() { _ = conn.Close() })
|
deregisterPAT := registerPATConnection(c, func() { _ = conn.Close() })
|
||||||
defer deregisterPAT()
|
defer deregisterPAT()
|
||||||
|
|
||||||
subID, ch := singleton.ServerTransferShared.Subscribe()
|
|
||||||
defer singleton.ServerTransferShared.Unsubscribe(subID)
|
|
||||||
|
|
||||||
// Pings keep the socket warm even when the broker is quiet. Without this
|
// Pings keep the socket warm even when the broker is quiet. Without this
|
||||||
// a long idle period followed by a transfer event would race against
|
// a long idle period followed by a transfer event would race against
|
||||||
// upstream proxy idle-timeouts that may have already closed the conn.
|
// upstream proxy idle-timeouts that may have already closed the conn.
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/cmd/dashboard/rpc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestWireNATDashboardCredentialGate pins the NAT ingress wiring contract:
|
||||||
|
// main() must connect the real batched dashboard-credential classifier
|
||||||
|
// (controller.ClassifyDashboardCredentialValues)
|
||||||
|
// through wireNATDashboardCredentialGate before any listener serves traffic.
|
||||||
|
// That the wired functions really classify per channel is pinned end to end
|
||||||
|
// by the controller package's real-classifier NAT flow test.
|
||||||
|
func TestWireNATDashboardCredentialGate(t *testing.T) {
|
||||||
|
if rpc.NATDashboardCredentialGateWired() {
|
||||||
|
t.Fatal("NAT dashboard credential gate already wired before the test")
|
||||||
|
}
|
||||||
|
|
||||||
|
wireNATDashboardCredentialGate()
|
||||||
|
|
||||||
|
if !rpc.NATDashboardCredentialGateWired() {
|
||||||
|
t.Fatal("wireNATDashboardCredentialGate left the NAT dashboard credential gate unwired")
|
||||||
|
}
|
||||||
|
// Leave the process in the wired state main() establishes.
|
||||||
|
t.Cleanup(wireNATDashboardCredentialGate)
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const dashboardReadHeaderTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
func newDashboardHTTPServer(handler http.Handler) *http.Server {
|
||||||
|
// ReadHeaderTimeout protects incomplete HTTP/1 requests and is cleared after
|
||||||
|
// headers. Unlike ReadTimeout it does not impose a lifetime on HTTP/2 streams.
|
||||||
|
return &http.Server{Handler: handler, ReadHeaderTimeout: dashboardReadHeaderTimeout}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDashboardRejectsIncompleteHTTPHeaders(t *testing.T) {
|
||||||
|
for _, useTLS := range []bool{false, true} {
|
||||||
|
name := "http"
|
||||||
|
if useTLS {
|
||||||
|
name = "https"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
server := httptest.NewUnstartedServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { t.Error("incomplete headers reached handler") }))
|
||||||
|
server.Config = newDashboardHTTPServer(server.Config.Handler)
|
||||||
|
require.Positive(t, server.Config.ReadHeaderTimeout)
|
||||||
|
server.Config.ReadHeaderTimeout = 50 * time.Millisecond
|
||||||
|
if useTLS {
|
||||||
|
server.StartTLS()
|
||||||
|
} else {
|
||||||
|
server.Start()
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
var conn net.Conn
|
||||||
|
var err error
|
||||||
|
if useTLS {
|
||||||
|
conn, err = tls.Dial("tcp", server.Listener.Addr().String(), &tls.Config{InsecureSkipVerify: true})
|
||||||
|
} else {
|
||||||
|
conn, err = net.Dial("tcp", server.Listener.Addr().String())
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer conn.Close()
|
||||||
|
require.NoError(t, conn.SetDeadline(time.Now().Add(2*time.Second)))
|
||||||
|
_, err = io.WriteString(conn, "GET / HTTP/1.1\r\nHost: dashboard\r\nX-Incomplete: ")
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = io.ReadAll(conn)
|
||||||
|
if networkErr, ok := err.(net.Error); ok {
|
||||||
|
require.False(t, networkErr.Timeout(), "server did not close incomplete request")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboardHeaderTimeoutDoesNotCancelHTTP2Stream(t *testing.T) {
|
||||||
|
for _, useTLS := range []bool{false, true} {
|
||||||
|
name := "h2c"
|
||||||
|
if useTLS {
|
||||||
|
name = "h2"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.ProtoMajor != 2 {
|
||||||
|
t.Errorf("expected HTTP/2, got %s", r.Proto)
|
||||||
|
}
|
||||||
|
require.NoError(t, http.NewResponseController(w).Flush())
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("long-lived request body: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(body)
|
||||||
|
})
|
||||||
|
server := httptest.NewUnstartedServer(handler)
|
||||||
|
server.Config = newDashboardHTTPServer(handler)
|
||||||
|
server.Config.ReadHeaderTimeout = 250 * time.Millisecond
|
||||||
|
server.Config.Protocols = new(http.Protocols)
|
||||||
|
server.Config.Protocols.SetHTTP1(true)
|
||||||
|
server.Config.Protocols.SetHTTP2(true)
|
||||||
|
server.Config.Protocols.SetUnencryptedHTTP2(true)
|
||||||
|
server.EnableHTTP2 = true
|
||||||
|
if useTLS {
|
||||||
|
server.StartTLS()
|
||||||
|
} else {
|
||||||
|
server.Start()
|
||||||
|
}
|
||||||
|
defer server.Close()
|
||||||
|
transport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, Protocols: new(http.Protocols)}
|
||||||
|
transport.Protocols.SetHTTP2(true)
|
||||||
|
transport.Protocols.SetUnencryptedHTTP2(true)
|
||||||
|
defer transport.CloseIdleConnections()
|
||||||
|
client := &http.Client{Transport: transport, Timeout: 10 * time.Second}
|
||||||
|
reader, writer := io.Pipe()
|
||||||
|
defer reader.Close()
|
||||||
|
defer writer.Close()
|
||||||
|
request, err := http.NewRequest(http.MethodPost, server.URL, reader)
|
||||||
|
require.NoError(t, err)
|
||||||
|
response, err := client.Do(request)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer response.Body.Close()
|
||||||
|
time.Sleep(4 * server.Config.ReadHeaderTimeout)
|
||||||
|
_, err = writer.Write([]byte("still streaming"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, writer.Close())
|
||||||
|
body, err := io.ReadAll(response.Body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "still streaming", string(body))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+26
-6
@@ -12,7 +12,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -86,6 +85,16 @@ func initSystem(bus chan<- *model.Service) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 在线会话兜底清扫:状态流异常断开(失联)时由这里关闭进行中的会话
|
||||||
|
if _, err := singleton.CronShared.AddFunc("@every 30s", singleton.SweepOnlineSessions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在线会话保留清理:超出后台设置的统计窗口后删除
|
||||||
|
if _, err := singleton.CronShared.AddFunc("0 30 4 * * *", singleton.PruneOnlineSessions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := singleton.StartJWTSessionGC(); err != nil {
|
if err := singleton.StartJWTSessionGC(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -178,12 +187,11 @@ func main() {
|
|||||||
grpcHandler := rpc.ServeRPC()
|
grpcHandler := rpc.ServeRPC()
|
||||||
httpHandler := controller.ServeWeb(frontendDist)
|
httpHandler := controller.ServeWeb(frontendDist)
|
||||||
controller.InitUpgrader()
|
controller.InitUpgrader()
|
||||||
|
wireNATDashboardCredentialGate()
|
||||||
|
|
||||||
muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler)
|
muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler)
|
||||||
muxServerHTTP := &http.Server{
|
muxServerHTTP := newDashboardHTTPServer(muxHandler)
|
||||||
Handler: muxHandler,
|
|
||||||
ReadHeaderTimeout: time.Second * 5,
|
|
||||||
}
|
|
||||||
muxServerHTTP.Protocols = new(http.Protocols)
|
muxServerHTTP.Protocols = new(http.Protocols)
|
||||||
muxServerHTTP.Protocols.SetHTTP1(true)
|
muxServerHTTP.Protocols.SetHTTP1(true)
|
||||||
muxServerHTTP.Protocols.SetUnencryptedHTTP2(true)
|
muxServerHTTP.Protocols.SetUnencryptedHTTP2(true)
|
||||||
@@ -191,9 +199,9 @@ func main() {
|
|||||||
var muxServerHTTPS *http.Server
|
var muxServerHTTPS *http.Server
|
||||||
if singleton.Conf.HTTPS.ListenPort != 0 {
|
if singleton.Conf.HTTPS.ListenPort != 0 {
|
||||||
muxServerHTTPS = &http.Server{
|
muxServerHTTPS = &http.Server{
|
||||||
|
ReadHeaderTimeout: dashboardReadHeaderTimeout,
|
||||||
Addr: fmt.Sprintf("%s:%d", singleton.Conf.ListenHost, singleton.Conf.HTTPS.ListenPort),
|
Addr: fmt.Sprintf("%s:%d", singleton.Conf.ListenHost, singleton.Conf.HTTPS.ListenPort),
|
||||||
Handler: muxHandler,
|
Handler: muxHandler,
|
||||||
ReadHeaderTimeout: time.Second * 5,
|
|
||||||
TLSConfig: &tls.Config{
|
TLSConfig: &tls.Config{
|
||||||
InsecureSkipVerify: singleton.Conf.HTTPS.InsecureTLS,
|
InsecureSkipVerify: singleton.Conf.HTTPS.InsecureTLS,
|
||||||
},
|
},
|
||||||
@@ -237,6 +245,18 @@ func main() {
|
|||||||
close(errChan)
|
close(errChan)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wireNATDashboardCredentialGate connects the NAT ingress to the real
|
||||||
|
// dashboard-credential classifiers. The NAT ingress strips dashboard-issued
|
||||||
|
// credentials (panel JWT / panel PAT) from every channel the panel's own
|
||||||
|
// TokenLookup accepts — the Authorization header, the ?token= query parameter
|
||||||
|
// and the nz-jwt cookie — before tunneling a request to the agent, and
|
||||||
|
// forwards foreign values untouched. ServeWeb built the JWT parser the
|
||||||
|
// classifier reuses, so the gate is wired right after it and before any
|
||||||
|
// listener starts serving.
|
||||||
|
func wireNATDashboardCredentialGate() {
|
||||||
|
rpc.SetNATDashboardCredentialGate(controller.ClassifyDashboardCredentialValues)
|
||||||
|
}
|
||||||
|
|
||||||
func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler {
|
func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
natConfig := singleton.NATShared.GetNATConfigByDomain(r.Host)
|
natConfig := singleton.NATShared.GetNATConfigByDomain(r.Host)
|
||||||
|
|||||||
@@ -80,7 +80,15 @@ func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization authenticates Dashboard access before NAT ingress; it must not become an origin credential for the configured NAT backend.
|
// Credential invariant at NAT ingress: credentials issued by this
|
||||||
|
// dashboard — the panel-signed JWT (verified by signature only, expiry
|
||||||
|
// deliberately ignored) and the panel API token — must never become origin
|
||||||
|
// credentials for the configured NAT backend. prepareNATCapability has
|
||||||
|
// already stripped them from every channel the panel's own TokenLookup
|
||||||
|
// accepts (Authorization header, ?token= query parameter, nz-jwt cookie);
|
||||||
|
// whatever remains on those channels is either absent or a foreign
|
||||||
|
// credential, which is forwarded untouched because the NAT backend may
|
||||||
|
// authenticate with credentials of its own.
|
||||||
wWrapped, err := utils.NewRequestWrapper(r, w)
|
wWrapped, err := utils.NewRequestWrapper(r, w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -22,16 +22,26 @@ type natCapabilityLease struct {
|
|||||||
func prepareNATCapability(request *http.Request, natConfig *model.NAT) (natCapabilityLease, error) {
|
func prepareNATCapability(request *http.Request, natConfig *model.NAT) (natCapabilityLease, error) {
|
||||||
values := request.Header.Values(agentcompatcontract.IOStreamCapabilityHeader)
|
values := request.Header.Values(agentcompatcontract.IOStreamCapabilityHeader)
|
||||||
if len(values) == 0 {
|
if len(values) == 0 {
|
||||||
request.Header.Del("Authorization")
|
// No capability header: legacy NAT path. Strip dashboard-issued
|
||||||
|
// credentials (panel JWT or panel PAT) from the Authorization header,
|
||||||
|
// the ?token= query parameter and the nz-jwt cookie so they never
|
||||||
|
// become origin credentials; foreign values are ordinary request data.
|
||||||
|
stripDashboardCredentials(request)
|
||||||
return natCapabilityLease{}, nil
|
return natCapabilityLease{}, nil
|
||||||
}
|
}
|
||||||
request.Header.Del(agentcompatcontract.IOStreamCapabilityHeader)
|
request.Header.Del(agentcompatcontract.IOStreamCapabilityHeader)
|
||||||
if len(values) != 1 || values[0] == "" {
|
if len(values) != 1 || values[0] == "" {
|
||||||
request.Header.Del("Authorization")
|
// Invalid capability: nothing is forwarded, but dashboard-issued
|
||||||
|
// credentials still must not survive into errors or task data.
|
||||||
|
stripDashboardCredentials(request)
|
||||||
return natCapabilityLease{}, errors.New("invalid NAT capability")
|
return natCapabilityLease{}, errors.New("invalid NAT capability")
|
||||||
}
|
}
|
||||||
access, handle, err := serviceRPC.NezhaHandlerSingleton.ConsumeAgentCompatNATCapabilityForProfile(values[0], natConfig.ServerID, natConfig.ID)
|
access, handle, err := serviceRPC.NezhaHandlerSingleton.ConsumeAgentCompatNATCapabilityForProfile(values[0], natConfig.ServerID, natConfig.ID)
|
||||||
request.Header.Del("Authorization")
|
// From here the request bytes may reach the agent (active lease) or the
|
||||||
|
// request ends in a 503; either way dashboard-issued credentials are
|
||||||
|
// stripped from every TokenLookup channel, foreign ones are forwarded
|
||||||
|
// untouched.
|
||||||
|
stripDashboardCredentials(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return natCapabilityLease{}, errors.New("invalid NAT capability")
|
return natCapabilityLease{}, errors.New("invalid NAT capability")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ package rpc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -59,7 +61,193 @@ func TestPrepareNATCapabilityRejectsDuplicateHeaderAfterRemovingAllValues(t *tes
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentials(t *testing.T) {
|
||||||
|
// Given — legacy path without a capability header, then the invalid
|
||||||
|
// capability path: both must drop dashboard-issued credentials from the
|
||||||
|
// Authorization header, the ?token= query parameter and the nz-jwt cookie.
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
for name, credential := range map[string]string{
|
||||||
|
"dashboard jwt": natTestDashboardJWTValue,
|
||||||
|
"expired dashboard jwt": natTestDashboardExpiredJWTValue,
|
||||||
|
"dashboard api token": natTestDashboardPATValue,
|
||||||
|
} {
|
||||||
|
t.Run(name+"/legacy path", func(t *testing.T) {
|
||||||
|
request := &http.Request{
|
||||||
|
Header: make(http.Header),
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+credential)
|
||||||
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
|
||||||
|
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacy NAT path returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("legacy NAT path unexpectedly activated")
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Fatalf("legacy NAT path retained dashboard credential in Authorization %q", got)
|
||||||
|
}
|
||||||
|
if got := request.URL.RawQuery; got != "keep=1" {
|
||||||
|
t.Fatalf("legacy NAT path retained dashboard credential in query %q", got)
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||||
|
t.Fatalf("legacy NAT path retained dashboard credential in cookie %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
t.Run(name+"/invalid capability path", func(t *testing.T) {
|
||||||
|
request := &http.Request{
|
||||||
|
Header: make(http.Header),
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
|
||||||
|
}
|
||||||
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed")
|
||||||
|
request.Header.Set("Authorization", "Bearer "+credential)
|
||||||
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
|
||||||
|
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("malformed capability unexpectedly accepted")
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("malformed capability unexpectedly activated")
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Fatalf("invalid capability path retained dashboard credential in Authorization %q", got)
|
||||||
|
}
|
||||||
|
if got := request.URL.RawQuery; got != "keep=1" {
|
||||||
|
t.Fatalf("invalid capability path retained dashboard credential in query %q", got)
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||||
|
t.Fatalf("invalid capability path retained dashboard credential in cookie %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityAgentCompatStripsDashboardCredentialAfterConsumingCapability(t *testing.T) {
|
||||||
|
// Given — a well-formed capability that consumes successfully: the request
|
||||||
|
// is about to be tunneled, so the dashboard credential must be gone.
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
handler := serviceRPC.NewNezhaHandler()
|
||||||
|
original := serviceRPC.NezhaHandlerSingleton
|
||||||
|
serviceRPC.NezhaHandlerSingleton = handler
|
||||||
|
t.Cleanup(func() { serviceRPC.NezhaHandlerSingleton = original })
|
||||||
|
|
||||||
|
capability, err := handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{
|
||||||
|
Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2},
|
||||||
|
Purpose: serviceRPC.AgentCompatCapabilityNAT,
|
||||||
|
TargetServerID: 81,
|
||||||
|
ResourceID: 91,
|
||||||
|
ServerAccessAllowed: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register capability: %v", err)
|
||||||
|
}
|
||||||
|
request := &http.Request{
|
||||||
|
Header: make(http.Header),
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue},
|
||||||
|
}
|
||||||
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String())
|
||||||
|
request.Header.Set("Authorization", natTestDashboardJWT)
|
||||||
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue)
|
||||||
|
|
||||||
|
// When
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid NAT capability rejected: %v", err)
|
||||||
|
}
|
||||||
|
if !lease.active {
|
||||||
|
t.Fatal("valid NAT capability did not activate")
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Fatalf("dashboard credential retained in Authorization after capability consumption: %q", got)
|
||||||
|
}
|
||||||
|
if got := request.URL.RawQuery; got != "keep=1" {
|
||||||
|
t.Fatalf("dashboard credential retained in query after capability consumption: %q", got)
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||||
|
t.Fatalf("dashboard credential retained in cookie after capability consumption: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingAuthorization(t *testing.T) {
|
||||||
|
// Given — legacy path: foreign credentials are ordinary request data and
|
||||||
|
// an absent header must stay untouched.
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
for name, values := range map[string][]string{
|
||||||
|
"foreign jwt": {natTestForeignJWT},
|
||||||
|
"foreign random": {natTestForeignRandom},
|
||||||
|
"empty value": {""},
|
||||||
|
"missing header": nil,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := &http.Request{Header: make(http.Header)}
|
||||||
|
for _, value := range values {
|
||||||
|
request.Header.Set("Authorization", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacy NAT path returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("legacy NAT path unexpectedly activated")
|
||||||
|
}
|
||||||
|
got := request.Header.Values("Authorization")
|
||||||
|
if len(got) != len(values) {
|
||||||
|
t.Fatalf("legacy NAT path changed Authorization to %q, want %q", got, values)
|
||||||
|
}
|
||||||
|
for i := range values {
|
||||||
|
if got[i] != values[i] {
|
||||||
|
t.Fatalf("legacy NAT path changed Authorization to %q, want %q", got, values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityAgentCompatKeepsForeignAndMissingQueryAndCookie(t *testing.T) {
|
||||||
|
// Given — legacy path: foreign query tokens and cookies are ordinary
|
||||||
|
// request data, and absent channels must stay untouched.
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
for name, tc := range map[string]struct{ rawQuery, cookie string }{
|
||||||
|
"foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue},
|
||||||
|
"empty values": {"keep=1&token=", "nz-jwt="},
|
||||||
|
"missing channel": {"keep=1", "sid=abc"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := &http.Request{
|
||||||
|
Header: make(http.Header),
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery},
|
||||||
|
}
|
||||||
|
request.Header.Set("Cookie", tc.cookie)
|
||||||
|
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("legacy NAT path returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("legacy NAT path unexpectedly activated")
|
||||||
|
}
|
||||||
|
if got := request.URL.RawQuery; got != tc.rawQuery {
|
||||||
|
t.Fatalf("legacy NAT path changed query to %q, want %q", got, tc.rawQuery)
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Cookie"); got != tc.cookie {
|
||||||
|
t.Fatalf("legacy NAT path changed cookie to %q, want %q", got, tc.cookie)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServeNATAgentCompatSensitiveHeadersStayOutOfErrorsAndLogs(t *testing.T) {
|
func TestServeNATAgentCompatSensitiveHeadersStayOutOfErrorsAndLogs(t *testing.T) {
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
request := &http.Request{Header: make(http.Header)}
|
request := &http.Request{Header: make(http.Header)}
|
||||||
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "capability-secret")
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "capability-secret")
|
||||||
request.Header.Set("Authorization", "Bearer deterministic-secret")
|
request.Header.Set("Authorization", "Bearer deterministic-secret")
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ type natCapabilityLease struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func prepareNATCapability(request *http.Request, _ *model.NAT) (natCapabilityLease, error) {
|
func prepareNATCapability(request *http.Request, _ *model.NAT) (natCapabilityLease, error) {
|
||||||
request.Header.Del("Authorization")
|
// The default build has no capability protocol: every request takes the
|
||||||
|
// legacy NAT path, so any dashboard-issued credential (panel JWT or panel
|
||||||
|
// PAT) carried by the Authorization header, the ?token= query parameter or
|
||||||
|
// the nz-jwt cookie is stripped before the request is tunneled to the
|
||||||
|
// agent. Foreign values are ordinary request data and stay put.
|
||||||
|
stripDashboardCredentials(request)
|
||||||
return natCapabilityLease{}, nil
|
return natCapabilityLease{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
|
|
||||||
func TestServeNATDefaultForwardsCapabilityHeaderAsOrdinaryData(t *testing.T) {
|
func TestServeNATDefaultForwardsCapabilityHeaderAsOrdinaryData(t *testing.T) {
|
||||||
fixture := newServeNATFixture(t)
|
fixture := newServeNATFixture(t)
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
connection := newServeNATConn()
|
connection := newServeNATConn()
|
||||||
agent := &serveNATAgent{readErr: io.EOF, writeDone: make(chan struct{})}
|
agent := &serveNATAgent{readErr: io.EOF, writeDone: make(chan struct{})}
|
||||||
request := &http.Request{
|
request := &http.Request{
|
||||||
@@ -28,7 +29,7 @@ func TestServeNATDefaultForwardsCapabilityHeaderAsOrdinaryData(t *testing.T) {
|
|||||||
Body: &serveNATBody{},
|
Body: &serveNATBody{},
|
||||||
}
|
}
|
||||||
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "ordinary-data")
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "ordinary-data")
|
||||||
request.Header.Set("Authorization", "Bearer deterministic-secret")
|
request.Header.Set("Authorization", natTestForeignRandom)
|
||||||
fixture.taskStream.onSend = func(task *proto.Task) error {
|
fixture.taskStream.onSend = func(task *proto.Task) error {
|
||||||
var nat model.TaskNAT
|
var nat model.TaskNAT
|
||||||
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
|
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
|
||||||
@@ -58,7 +59,50 @@ func TestServeNATDefaultForwardsCapabilityHeaderAsOrdinaryData(t *testing.T) {
|
|||||||
if !strings.Contains(forwarded, strings.ToLower(agentcompatcontract.IOStreamCapabilityHeader)+": ordinary-data") {
|
if !strings.Contains(forwarded, strings.ToLower(agentcompatcontract.IOStreamCapabilityHeader)+": ordinary-data") {
|
||||||
t.Fatal("default NAT flow did not forward capability header as ordinary request data")
|
t.Fatal("default NAT flow did not forward capability header as ordinary request data")
|
||||||
}
|
}
|
||||||
if strings.Contains(forwarded, "Authorization:") || strings.Contains(forwarded, "deterministic-secret") {
|
if !strings.Contains(forwarded, strings.ToLower("Authorization: "+natTestForeignRandom)) {
|
||||||
t.Fatal("default NAT flow forwarded Authorization credentials")
|
t.Fatal("default NAT flow stripped a foreign credential that does not belong to the dashboard")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeNATDefaultDoesNotForwardDashboardCredentials(t *testing.T) {
|
||||||
|
fixture := newServeNATFixture(t)
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
connection := newServeNATConn()
|
||||||
|
agent := &serveNATAgent{readErr: io.EOF, writeDone: make(chan struct{})}
|
||||||
|
request := &http.Request{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "example.test", Path: "/nat"},
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: &serveNATBody{},
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", natTestDashboardJWT)
|
||||||
|
fixture.taskStream.onSend = func(task *proto.Task) error {
|
||||||
|
var nat model.TaskNAT
|
||||||
|
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fixture.handler.AgentConnected(nat.StreamID, agent)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
deadline, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
go func() {
|
||||||
|
ServeNAT(&serveNATResponseWriter{conn: connection}, request, &model.NAT{ServerID: fixture.server.ID, Host: "target.example"})
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-agent.writeDone:
|
||||||
|
case <-deadline.Done():
|
||||||
|
t.Fatal("agent did not receive ordinary NAT request")
|
||||||
|
}
|
||||||
|
_ = connection.Close()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-deadline.Done():
|
||||||
|
t.Fatal("ServeNAT did not finish")
|
||||||
|
}
|
||||||
|
forwarded := strings.ToLower(string(agent.writtenBytes()))
|
||||||
|
if strings.Contains(forwarded, "authorization:") || strings.Contains(forwarded, "dashboard-signed-jwt") {
|
||||||
|
t.Fatal("default NAT flow forwarded dashboard credentials")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ package rpc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/nezhahq/nezha/model"
|
"github.com/nezhahq/nezha/model"
|
||||||
@@ -12,9 +13,10 @@ import (
|
|||||||
|
|
||||||
func TestPrepareNATCapabilityDefaultPreservesHeaderAsOrdinaryRequestData(t *testing.T) {
|
func TestPrepareNATCapabilityDefaultPreservesHeaderAsOrdinaryRequestData(t *testing.T) {
|
||||||
// Given
|
// Given
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
request := &http.Request{Header: make(http.Header)}
|
request := &http.Request{Header: make(http.Header)}
|
||||||
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "ordinary-data")
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "ordinary-data")
|
||||||
request.Header.Set("Authorization", "Bearer deterministic-secret")
|
request.Header.Set("Authorization", natTestForeignRandom)
|
||||||
|
|
||||||
// When
|
// When
|
||||||
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
@@ -29,7 +31,119 @@ func TestPrepareNATCapabilityDefaultPreservesHeaderAsOrdinaryRequestData(t *test
|
|||||||
if got := request.Header.Get(agentcompatcontract.IOStreamCapabilityHeader); got != "ordinary-data" {
|
if got := request.Header.Get(agentcompatcontract.IOStreamCapabilityHeader); got != "ordinary-data" {
|
||||||
t.Fatalf("default NAT capability hook changed header to %q", got)
|
t.Fatalf("default NAT capability hook changed header to %q", got)
|
||||||
}
|
}
|
||||||
|
if got := request.Header.Get("Authorization"); got != natTestForeignRandom {
|
||||||
|
t.Fatalf("default NAT capability hook changed foreign Authorization to %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityDefaultStripsDashboardCredentials(t *testing.T) {
|
||||||
|
// Given
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
for name, credential := range map[string]string{
|
||||||
|
"dashboard jwt": natTestDashboardJWTValue,
|
||||||
|
"expired dashboard jwt": natTestDashboardExpiredJWTValue,
|
||||||
|
"dashboard api token": natTestDashboardPATValue,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := &http.Request{
|
||||||
|
Header: make(http.Header),
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + credential},
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", "Bearer "+credential)
|
||||||
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+credential)
|
||||||
|
|
||||||
|
// When
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default NAT capability hook returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("default NAT capability hook unexpectedly activated")
|
||||||
|
}
|
||||||
if got := request.Header.Get("Authorization"); got != "" {
|
if got := request.Header.Get("Authorization"); got != "" {
|
||||||
t.Fatalf("default NAT capability hook retained Authorization %q", got)
|
t.Fatalf("default NAT capability hook retained dashboard credential in Authorization %q", got)
|
||||||
|
}
|
||||||
|
if got := request.URL.RawQuery; got != "keep=1" {
|
||||||
|
t.Fatalf("default NAT capability hook retained dashboard credential in query %q", got)
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||||
|
t.Fatalf("default NAT capability hook retained dashboard credential in cookie %q", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingAuthorization(t *testing.T) {
|
||||||
|
// Given
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
for name, values := range map[string][]string{
|
||||||
|
"foreign jwt": {natTestForeignJWT},
|
||||||
|
"foreign random": {natTestForeignRandom},
|
||||||
|
"empty value": {""},
|
||||||
|
"missing header": nil,
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := &http.Request{Header: make(http.Header)}
|
||||||
|
for _, value := range values {
|
||||||
|
request.Header.Set("Authorization", value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// When
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default NAT capability hook returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("default NAT capability hook unexpectedly activated")
|
||||||
|
}
|
||||||
|
got := request.Header.Values("Authorization")
|
||||||
|
if len(got) != len(values) {
|
||||||
|
t.Fatalf("default NAT capability hook changed Authorization to %q, want %q", got, values)
|
||||||
|
}
|
||||||
|
for i := range values {
|
||||||
|
if got[i] != values[i] {
|
||||||
|
t.Fatalf("default NAT capability hook changed Authorization to %q, want %q", got, values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityDefaultKeepsForeignAndMissingQueryAndCookie(t *testing.T) {
|
||||||
|
// Given
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
for name, tc := range map[string]struct{ rawQuery, cookie string }{
|
||||||
|
"foreign values": {"keep=1&token=" + natTestForeignJWTValue + "&a=%2Fx", "sid=abc; nz-jwt=" + natTestForeignJWTValue},
|
||||||
|
"empty values": {"keep=1&token=", "nz-jwt="},
|
||||||
|
"missing channel": {"keep=1", "sid=abc"},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
request := &http.Request{
|
||||||
|
Header: make(http.Header),
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: tc.rawQuery},
|
||||||
|
}
|
||||||
|
request.Header.Set("Cookie", tc.cookie)
|
||||||
|
|
||||||
|
// When
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("default NAT capability hook returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("default NAT capability hook unexpectedly activated")
|
||||||
|
}
|
||||||
|
if got := request.URL.RawQuery; got != tc.rawQuery {
|
||||||
|
t.Fatalf("default NAT capability hook changed query to %q, want %q", got, tc.rawQuery)
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Cookie"); got != tc.cookie {
|
||||||
|
t.Fatalf("default NAT capability hook changed cookie to %q, want %q", got, tc.cookie)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
func TestServeNATAgentCompatPublishesExactStreamAfterRequestTransfer(t *testing.T) {
|
func TestServeNATAgentCompatPublishesExactStreamAfterRequestTransfer(t *testing.T) {
|
||||||
fixture := newServeNATFixture(t)
|
fixture := newServeNATFixture(t)
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
capability, err := fixture.handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{
|
capability, err := fixture.handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{
|
||||||
Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2},
|
Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2},
|
||||||
Purpose: serviceRPC.AgentCompatCapabilityNAT,
|
Purpose: serviceRPC.AgentCompatCapabilityNAT,
|
||||||
@@ -44,7 +45,7 @@ func TestServeNATAgentCompatPublishesExactStreamAfterRequestTransfer(t *testing.
|
|||||||
Body: io.NopCloser(strings.NewReader("payload")),
|
Body: io.NopCloser(strings.NewReader("payload")),
|
||||||
}
|
}
|
||||||
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String())
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String())
|
||||||
request.Header.Set("Authorization", "Bearer deterministic-secret")
|
request.Header.Set("Authorization", natTestForeignJWT)
|
||||||
request.Header.Set("X-Ordinary-NAT", "ordinary")
|
request.Header.Set("X-Ordinary-NAT", "ordinary")
|
||||||
fixture.taskStream.onSend = func(task *proto.Task) error {
|
fixture.taskStream.onSend = func(task *proto.Task) error {
|
||||||
var nat model.TaskNAT
|
var nat model.TaskNAT
|
||||||
@@ -106,8 +107,62 @@ func TestServeNATAgentCompatPublishesExactStreamAfterRequestTransfer(t *testing.
|
|||||||
require.Contains(t, forwarded, expected)
|
require.Contains(t, forwarded, expected)
|
||||||
}
|
}
|
||||||
require.NotContains(t, forwarded, agentcompatcontract.IOStreamCapabilityHeader)
|
require.NotContains(t, forwarded, agentcompatcontract.IOStreamCapabilityHeader)
|
||||||
require.NotContains(t, forwarded, "Authorization:")
|
// Foreign credentials are ordinary request data for the NAT backend and
|
||||||
require.NotContains(t, forwarded, "deterministic-secret")
|
// must survive the transfer; dashboard-issued values are stripped before
|
||||||
|
// any byte leaves the dashboard.
|
||||||
|
require.Contains(t, forwarded, "Authorization: "+natTestForeignJWT)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServeNATAgentCompatDoesNotForwardDashboardCredentials(t *testing.T) {
|
||||||
|
fixture := newServeNATFixture(t)
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
capability, err := fixture.handler.RegisterAgentCompatIOStreamCapability(context.Background(), serviceRPC.AgentCompatCapabilityRegistration{
|
||||||
|
Owner: serviceRPC.AgentCompatCapabilityOwner{PATID: 1, UserID: 2},
|
||||||
|
Purpose: serviceRPC.AgentCompatCapabilityNAT,
|
||||||
|
TargetServerID: fixture.server.ID,
|
||||||
|
ResourceID: 91,
|
||||||
|
ServerAccessAllowed: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
connection := newServeNATConn()
|
||||||
|
writer := &serveNATResponseWriter{conn: connection}
|
||||||
|
agent := &serveNATAgent{readErr: io.EOF, writeDone: make(chan struct{})}
|
||||||
|
request := &http.Request{
|
||||||
|
Method: http.MethodPost,
|
||||||
|
URL: &url.URL{Scheme: "http", Host: "example.test", Path: "/nat"},
|
||||||
|
Header: make(http.Header),
|
||||||
|
Body: io.NopCloser(strings.NewReader("payload")),
|
||||||
|
}
|
||||||
|
request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, capability.String())
|
||||||
|
request.Header.Set("Authorization", natTestDashboardJWT)
|
||||||
|
fixture.taskStream.onSend = func(task *proto.Task) error {
|
||||||
|
var nat model.TaskNAT
|
||||||
|
if err := json.Unmarshal([]byte(task.Data), &nat); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return fixture.handler.AgentConnected(nat.StreamID, agent)
|
||||||
|
}
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
ServeNAT(writer, request, &model.NAT{Common: model.Common{ID: 91}, ServerID: fixture.server.ID, Host: "target.example"})
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-agent.writeDone:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("agent did not receive the transferred NAT request")
|
||||||
|
}
|
||||||
|
require.NoError(t, connection.Close())
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ServeNAT did not complete")
|
||||||
|
}
|
||||||
|
forwarded := strings.ToLower(string(agent.writtenBytes()))
|
||||||
|
require.NotContains(t, forwarded, "authorization:")
|
||||||
|
for _, dashboardCredential := range []string{natTestDashboardJWT, natTestDashboardExpiredJWT, natTestDashboardAPIToken} {
|
||||||
|
require.NotContains(t, forwarded, strings.ToLower(dashboardCredential))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type orderedNATAgent struct {
|
type orderedNATAgent struct {
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Channel names of the panel's own TokenLookup (controller/jwt.go
|
||||||
|
// "header: Authorization, query: token, cookie: nz-jwt"). A dashboard JWT
|
||||||
|
// can therefore reach the NAT ingress through the Authorization header, the
|
||||||
|
// ?token= query parameter or the nz-jwt cookie, and all three must be
|
||||||
|
// classified before the request is tunneled to the agent.
|
||||||
|
const (
|
||||||
|
natDashboardJWTCookieName = "nz-jwt"
|
||||||
|
natDashboardJWTQueryParameter = "token"
|
||||||
|
// Keep credential classification work constant even when an unauthenticated
|
||||||
|
// NAT request contains thousands of token parameters or cookies. Values
|
||||||
|
// beyond this per-request budget fail closed and are stripped.
|
||||||
|
maxNATDashboardCredentialValues = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
natDashboardCredentialGateMu sync.RWMutex
|
||||||
|
// natDashboardCredentialValueGate classifies a bounded batch of raw
|
||||||
|
// credential values. Batching lets the controller resolve all PAT hashes in
|
||||||
|
// one query instead of turning attacker-controlled query/cookie fan-out into
|
||||||
|
// one database query per value.
|
||||||
|
natDashboardCredentialValueGate func(ctx context.Context, values []string) []bool
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetNATDashboardCredentialGate wires the dashboard-credential classifier
|
||||||
|
// used by the NAT ingress. main() injects
|
||||||
|
// controller.ClassifyDashboardCredentialValues;
|
||||||
|
// the setter keeps the rpc package free of a dependency on the controller
|
||||||
|
// package, the same pattern as SetReceiptGateListener and
|
||||||
|
// SetMCPKillSwitchObserver. A nil function fails closed: every value on that
|
||||||
|
// channel is then treated as dashboard-issued and stripped.
|
||||||
|
func SetNATDashboardCredentialGate(valueClass func(ctx context.Context, values []string) []bool) {
|
||||||
|
natDashboardCredentialGateMu.Lock()
|
||||||
|
natDashboardCredentialValueGate = valueClass
|
||||||
|
natDashboardCredentialGateMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATDashboardCredentialGateWired reports whether the dashboard-credential
|
||||||
|
// classifier has been wired. It exists so the main() wiring contract can be
|
||||||
|
// pinned by a test.
|
||||||
|
func NATDashboardCredentialGateWired() bool {
|
||||||
|
natDashboardCredentialGateMu.RLock()
|
||||||
|
wired := natDashboardCredentialValueGate != nil
|
||||||
|
natDashboardCredentialGateMu.RUnlock()
|
||||||
|
return wired
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripDashboardCredentials removes credentials issued by this dashboard from
|
||||||
|
// every channel the panel's own TokenLookup accepts: the Authorization
|
||||||
|
// header, the ?token= query parameter and the nz-jwt cookie. Such credentials
|
||||||
|
// authenticate the dashboard itself and must never become origin credentials
|
||||||
|
// for the NAT backend. Foreign values are forwarded untouched: the NAT
|
||||||
|
// backend may authenticate with credentials of its own, and this dashboard
|
||||||
|
// must not make policy for them.
|
||||||
|
func stripDashboardCredentials(request *http.Request) {
|
||||||
|
classification := classifyNATDashboardCredentialValues(request)
|
||||||
|
stripDashboardAuthorizationHeader(request, classification)
|
||||||
|
stripDashboardCredentialQuery(request, classification)
|
||||||
|
stripDashboardCredentialCookie(request, classification)
|
||||||
|
}
|
||||||
|
|
||||||
|
type natDashboardCredentialClassification struct {
|
||||||
|
wired bool
|
||||||
|
limited bool
|
||||||
|
issued map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c natDashboardCredentialClassification) isDashboardCredential(value string) bool {
|
||||||
|
if !c.wired {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if issued, ok := c.issued[value]; ok {
|
||||||
|
return issued
|
||||||
|
}
|
||||||
|
// Only candidate values collected from this request reach this method. A
|
||||||
|
// missing result therefore means the distinct-value budget was exceeded;
|
||||||
|
// fail closed so a dashboard credential cannot be hidden after junk values.
|
||||||
|
return c.limited
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyNATDashboardCredentialValues(request *http.Request) natDashboardCredentialClassification {
|
||||||
|
natDashboardCredentialGateMu.RLock()
|
||||||
|
gate := natDashboardCredentialValueGate
|
||||||
|
natDashboardCredentialGateMu.RUnlock()
|
||||||
|
classification := natDashboardCredentialClassification{
|
||||||
|
wired: gate != nil,
|
||||||
|
issued: make(map[string]bool),
|
||||||
|
}
|
||||||
|
if gate == nil {
|
||||||
|
return classification
|
||||||
|
}
|
||||||
|
|
||||||
|
values, limited := collectNATDashboardCredentialValues(request)
|
||||||
|
classification.limited = limited
|
||||||
|
results := gate(request.Context(), values)
|
||||||
|
if len(results) != len(values) {
|
||||||
|
// A broken classifier must not turn into credential forwarding.
|
||||||
|
for _, value := range values {
|
||||||
|
classification.issued[value] = true
|
||||||
|
}
|
||||||
|
classification.limited = true
|
||||||
|
return classification
|
||||||
|
}
|
||||||
|
for i, value := range values {
|
||||||
|
classification.issued[value] = results[i]
|
||||||
|
}
|
||||||
|
return classification
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectNATDashboardCredentialValues(request *http.Request) ([]string, bool) {
|
||||||
|
values := make([]string, 0, maxNATDashboardCredentialValues)
|
||||||
|
seen := make(map[string]struct{}, maxNATDashboardCredentialValues)
|
||||||
|
limited := false
|
||||||
|
add := func(value string) {
|
||||||
|
if _, ok := seen[value]; ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(values) == maxNATDashboardCredentialValues {
|
||||||
|
limited = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen[value] = struct{}{}
|
||||||
|
values = append(values, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, authorization := range request.Header.Values("Authorization") {
|
||||||
|
raw := strings.TrimSpace(authorization)
|
||||||
|
if strings.HasPrefix(raw, "Bearer ") {
|
||||||
|
add(strings.TrimPrefix(raw, "Bearer "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.URL != nil {
|
||||||
|
for _, element := range strings.Split(request.URL.RawQuery, "&") {
|
||||||
|
if value, ok := dashboardCredentialQueryElementValue(element); ok {
|
||||||
|
add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, line := range request.Header.Values("Cookie") {
|
||||||
|
for _, pair := range strings.Split(line, ";") {
|
||||||
|
if value, ok := dashboardCredentialCookiePairValue(pair); ok {
|
||||||
|
add(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, limited
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripDashboardAuthorizationHeader removes the Authorization header when it
|
||||||
|
// carries a dashboard-issued credential.
|
||||||
|
func stripDashboardAuthorizationHeader(request *http.Request, classification natDashboardCredentialClassification) {
|
||||||
|
if !classification.wired {
|
||||||
|
// Gate not wired (early startup or tests): fail closed and keep the
|
||||||
|
// historical behavior of stripping every Authorization header.
|
||||||
|
request.Header.Del("Authorization")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// A multi-valued Authorization header is malformed traffic, but any
|
||||||
|
// dashboard-issued value in it must still not survive into the tunnel, so
|
||||||
|
// the whole header is dropped when one is recognized.
|
||||||
|
for _, value := range request.Header.Values("Authorization") {
|
||||||
|
raw := strings.TrimSpace(value)
|
||||||
|
if strings.HasPrefix(raw, "Bearer ") && classification.isDashboardCredential(strings.TrimPrefix(raw, "Bearer ")) {
|
||||||
|
request.Header.Del("Authorization")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripDashboardCredentialQuery removes ?token= query parameters whose value
|
||||||
|
// is a dashboard-issued credential. Every other element of the query string —
|
||||||
|
// including foreign token values — is preserved byte-for-byte, so the
|
||||||
|
// forwarded request keeps the exact query the client sent.
|
||||||
|
func stripDashboardCredentialQuery(request *http.Request, classification natDashboardCredentialClassification) {
|
||||||
|
if request.URL == nil || request.URL.RawQuery == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elements := strings.Split(request.URL.RawQuery, "&")
|
||||||
|
kept := elements[:0]
|
||||||
|
changed := false
|
||||||
|
for _, element := range elements {
|
||||||
|
if !classification.wired && dashboardCredentialQueryElementName(element) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, candidate := dashboardCredentialQueryElementValue(element)
|
||||||
|
if candidate && classification.isDashboardCredential(value) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, element)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
request.URL.RawQuery = strings.Join(kept, "&")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dashboardCredentialQueryElementValue extracts the decoded value from a raw
|
||||||
|
// query element carrying the panel's token parameter.
|
||||||
|
func dashboardCredentialQueryElementValue(element string) (string, bool) {
|
||||||
|
rawName, rawValue, _ := strings.Cut(element, "=")
|
||||||
|
// The panel reads the parameter by its decoded name (gin-jwt c.Query),
|
||||||
|
// so an escaped key must be decoded before comparing.
|
||||||
|
name, err := url.QueryUnescape(rawName)
|
||||||
|
if err != nil || name != natDashboardJWTQueryParameter {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// A value that fails to decode is one the panel's own query parsing drops
|
||||||
|
// as well, so it can never be a panel credential.
|
||||||
|
decoded, err := url.QueryUnescape(rawValue)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return decoded, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardCredentialQueryElementName(element string) bool {
|
||||||
|
rawName, _, _ := strings.Cut(element, "=")
|
||||||
|
name, err := url.QueryUnescape(rawName)
|
||||||
|
return err == nil && name == natDashboardJWTQueryParameter
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripDashboardCredentialCookie removes nz-jwt cookies whose value is a
|
||||||
|
// dashboard-issued credential. All other cookie pairs — including foreign
|
||||||
|
// nz-jwt values — are preserved verbatim.
|
||||||
|
func stripDashboardCredentialCookie(request *http.Request, classification natDashboardCredentialClassification) {
|
||||||
|
lines := request.Header.Values("Cookie")
|
||||||
|
if len(lines) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
kept := make([]string, 0, len(lines))
|
||||||
|
for _, line := range lines {
|
||||||
|
nextLine, lineChanged := stripDashboardCredentialCookieLine(line, classification)
|
||||||
|
changed = changed || lineChanged
|
||||||
|
if nextLine != "" {
|
||||||
|
kept = append(kept, nextLine)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
request.Header.Del("Cookie")
|
||||||
|
for _, line := range kept {
|
||||||
|
request.Header.Add("Cookie", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripDashboardCredentialCookieLine rewrites a single Cookie header line,
|
||||||
|
// dropping the dashboard-issued nz-jwt pairs. Kept pairs keep their raw form,
|
||||||
|
// whitespace included — except that a pair following a dropped one no longer
|
||||||
|
// carries the removed pair's share of the "; " separator.
|
||||||
|
func stripDashboardCredentialCookieLine(line string, classification natDashboardCredentialClassification) (string, bool) {
|
||||||
|
pairs := strings.Split(line, ";")
|
||||||
|
kept := pairs[:0]
|
||||||
|
droppedBeforeFirstKept := true
|
||||||
|
changed := false
|
||||||
|
for _, pair := range pairs {
|
||||||
|
if !classification.wired && dashboardCredentialCookiePairName(pair) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
value, candidate := dashboardCredentialCookiePairValue(pair)
|
||||||
|
if candidate && classification.isDashboardCredential(value) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if droppedBeforeFirstKept {
|
||||||
|
pair = strings.TrimLeft(pair, " ")
|
||||||
|
}
|
||||||
|
droppedBeforeFirstKept = false
|
||||||
|
kept = append(kept, pair)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return line, false
|
||||||
|
}
|
||||||
|
return strings.Join(kept, ";"), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// dashboardCredentialCookiePairValue extracts a cookie value the same way the
|
||||||
|
// panel hands it to the JWT verifier: surrounding whitespace/quotes are
|
||||||
|
// presentation, then Gin applies QueryUnescape.
|
||||||
|
func dashboardCredentialCookiePairValue(pair string) (string, bool) {
|
||||||
|
name, rawValue, found := strings.Cut(pair, "=")
|
||||||
|
if !found || strings.TrimSpace(name) != natDashboardJWTCookieName {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
value := strings.Trim(strings.TrimSpace(rawValue), `"`)
|
||||||
|
decoded, err := url.QueryUnescape(value)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return decoded, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func dashboardCredentialCookiePairName(pair string) bool {
|
||||||
|
name, _, found := strings.Cut(pair, "=")
|
||||||
|
return found && strings.TrimSpace(name) == natDashboardJWTCookieName
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Values standing in for the credentials this dashboard issues. The fake
|
||||||
|
// gates below mirror the contract of controller.IsDashboardCredential and
|
||||||
|
// controller.IsDashboardCredentialValue; the real classification semantics —
|
||||||
|
// panel JWTs accepted regardless of expiry, foreign-signed JWTs and random
|
||||||
|
// strings rejected — are pinned by the controller package tests
|
||||||
|
// (dashboard_credential_test.go) and by the flow test that wires the real
|
||||||
|
// classifiers into ServeNAT (dashboard_credential_nat_flow_test.go).
|
||||||
|
const (
|
||||||
|
natTestDashboardJWTValue = "dashboard-signed-jwt"
|
||||||
|
natTestDashboardExpiredJWTValue = "dashboard-signed-expired-jwt"
|
||||||
|
natTestDashboardPATValue = model.APITokenPrefix + "dashboard-issued-pat"
|
||||||
|
natTestForeignJWTValue = "foreign-signed-jwt"
|
||||||
|
natTestForeignRandomValue = "foreign-random-string"
|
||||||
|
|
||||||
|
natTestDashboardJWT = "Bearer " + natTestDashboardJWTValue
|
||||||
|
natTestDashboardExpiredJWT = "Bearer " + natTestDashboardExpiredJWTValue
|
||||||
|
natTestDashboardAPIToken = "Bearer " + natTestDashboardPATValue
|
||||||
|
natTestForeignJWT = "Bearer " + natTestForeignJWTValue
|
||||||
|
natTestForeignRandom = "Bearer " + natTestForeignRandomValue
|
||||||
|
)
|
||||||
|
|
||||||
|
// installNATCredentialTestGate wires fake dashboard-credential classifiers
|
||||||
|
// for the duration of the test: exactly the dashboard-issued test values above
|
||||||
|
// are classified as dashboard credentials on every channel, everything else
|
||||||
|
// is foreign.
|
||||||
|
func installNATCredentialTestGate(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
natDashboardCredentialGateMu.RLock()
|
||||||
|
original := natDashboardCredentialValueGate
|
||||||
|
natDashboardCredentialGateMu.RUnlock()
|
||||||
|
SetNATDashboardCredentialGate(func(_ context.Context, values []string) []bool {
|
||||||
|
results := make([]bool, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
switch value {
|
||||||
|
case natTestDashboardJWTValue, natTestDashboardExpiredJWTValue, natTestDashboardPATValue:
|
||||||
|
results[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
})
|
||||||
|
t.Cleanup(func() {
|
||||||
|
SetNATDashboardCredentialGate(original)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDashboardCredentialsQueryChannel(t *testing.T) {
|
||||||
|
// Given
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestDashboardJWTValue + "&token=" + natTestForeignJWTValue + "&a=%2Fx"}}
|
||||||
|
|
||||||
|
// When
|
||||||
|
stripDashboardCredentials(request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if got := request.URL.RawQuery; got != "keep=1&token="+natTestForeignJWTValue+"&a=%2Fx" {
|
||||||
|
t.Fatalf("query channel kept %q, want dashboard token dropped and foreign values byte-preserved", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDashboardCredentialsQueryChannelEscapedValue(t *testing.T) {
|
||||||
|
// Given — a dashboard token percent-encoded in the query must still be
|
||||||
|
// recognized, while the escaped foreign element stays verbatim.
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
request := &http.Request{URL: &url.URL{Path: "/nat", RawQuery: "token=" + url.QueryEscape(natTestDashboardJWTValue) + "&token=" + url.QueryEscape(natTestForeignJWTValue)}}
|
||||||
|
|
||||||
|
// When
|
||||||
|
stripDashboardCredentials(request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if got := request.URL.RawQuery; got != "token="+url.QueryEscape(natTestForeignJWTValue) {
|
||||||
|
t.Fatalf("query channel kept %q, want escaped dashboard token dropped and foreign element verbatim", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDashboardCredentialsCookieChannel(t *testing.T) {
|
||||||
|
// Given
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
request := &http.Request{Header: make(http.Header)}
|
||||||
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestDashboardJWTValue+"; nz-jwt="+natTestForeignJWTValue+"; theme=dark")
|
||||||
|
|
||||||
|
// When
|
||||||
|
stripDashboardCredentials(request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc; nz-jwt="+natTestForeignJWTValue+"; theme=dark" {
|
||||||
|
t.Fatalf("cookie channel kept %q, want dashboard pair dropped and foreign pairs verbatim", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDashboardCredentialsCookieChannelQuotedValue(t *testing.T) {
|
||||||
|
// Given — quoting is presentation the panel's cookie parsing strips before
|
||||||
|
// verifying, so a quoted dashboard token must be recognized.
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
request := &http.Request{Header: make(http.Header)}
|
||||||
|
request.Header.Set("Cookie", `nz-jwt="`+natTestDashboardJWTValue+`"; sid=abc`)
|
||||||
|
|
||||||
|
// When
|
||||||
|
stripDashboardCredentials(request)
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||||
|
t.Fatalf("cookie channel kept %q, want quoted dashboard token dropped", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDashboardCredentialsCookieChannelEscapedValue(t *testing.T) {
|
||||||
|
// Gin's cookie TokenLookup URL-decodes the value before JWT validation.
|
||||||
|
// NAT must classify the same decoded value or an encoded dashboard JWT can
|
||||||
|
// be accepted by the panel and then leak unchanged to the backend.
|
||||||
|
installNATCredentialTestGate(t)
|
||||||
|
request := &http.Request{Header: make(http.Header)}
|
||||||
|
escaped := strings.ReplaceAll(natTestDashboardJWTValue, "-", "%2D")
|
||||||
|
request.Header.Set("Cookie", "nz-jwt="+escaped+"; sid=abc")
|
||||||
|
|
||||||
|
stripDashboardCredentials(request)
|
||||||
|
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||||
|
t.Fatalf("cookie channel kept %q, want escaped dashboard token dropped", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripDashboardCredentialsDeduplicatesAndBoundsClassification(t *testing.T) {
|
||||||
|
natDashboardCredentialGateMu.RLock()
|
||||||
|
original := natDashboardCredentialValueGate
|
||||||
|
natDashboardCredentialGateMu.RUnlock()
|
||||||
|
t.Cleanup(func() { SetNATDashboardCredentialGate(original) })
|
||||||
|
|
||||||
|
classifiedValues := 0
|
||||||
|
gateCalls := 0
|
||||||
|
SetNATDashboardCredentialGate(func(_ context.Context, values []string) []bool {
|
||||||
|
gateCalls++
|
||||||
|
classifiedValues += len(values)
|
||||||
|
return make([]bool, len(values))
|
||||||
|
})
|
||||||
|
|
||||||
|
query := make(url.Values)
|
||||||
|
for range 100 {
|
||||||
|
query.Add("token", model.APITokenPrefix+"same-value")
|
||||||
|
}
|
||||||
|
for i := range 100 {
|
||||||
|
query.Add("token", fmt.Sprintf("%sunique-%d", model.APITokenPrefix, i))
|
||||||
|
}
|
||||||
|
request := &http.Request{Header: make(http.Header), URL: &url.URL{RawQuery: query.Encode()}}
|
||||||
|
|
||||||
|
stripDashboardCredentials(request)
|
||||||
|
|
||||||
|
if gateCalls != 1 {
|
||||||
|
t.Fatalf("classifier called %d times, want one batched call", gateCalls)
|
||||||
|
}
|
||||||
|
if classifiedValues > maxNATDashboardCredentialValues {
|
||||||
|
t.Fatalf("classified %d credential values, want at most %d per request", classifiedValues, maxNATDashboardCredentialValues)
|
||||||
|
}
|
||||||
|
if strings.Contains(request.URL.RawQuery, model.APITokenPrefix+"unique-99") {
|
||||||
|
t.Fatal("credential beyond the classification budget survived; overflow must fail closed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareNATCapabilityFailsClosedWithoutGate(t *testing.T) {
|
||||||
|
// Given — unwired gates: every value on every channel is treated as
|
||||||
|
// dashboard-issued and stripped, foreign ones included.
|
||||||
|
SetNATDashboardCredentialGate(nil)
|
||||||
|
t.Cleanup(func() { SetNATDashboardCredentialGate(nil) })
|
||||||
|
request := &http.Request{
|
||||||
|
Header: make(http.Header),
|
||||||
|
URL: &url.URL{Path: "/nat", RawQuery: "keep=1&token=" + natTestForeignJWTValue + "&token=%ZZ"},
|
||||||
|
}
|
||||||
|
request.Header.Set("Authorization", natTestForeignRandom)
|
||||||
|
request.Header.Set("Cookie", "sid=abc; nz-jwt="+natTestForeignJWTValue+"; nz-jwt=%ZZ")
|
||||||
|
|
||||||
|
// When
|
||||||
|
lease, err := prepareNATCapability(request, &model.NAT{Common: model.Common{ID: 91}, ServerID: 81})
|
||||||
|
|
||||||
|
// Then
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fail-closed NAT capability hook returned error: %v", err)
|
||||||
|
}
|
||||||
|
if lease.active {
|
||||||
|
t.Fatal("fail-closed NAT capability hook unexpectedly activated")
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Authorization"); got != "" {
|
||||||
|
t.Fatalf("fail-closed hook retained Authorization %q", got)
|
||||||
|
}
|
||||||
|
if got := request.URL.RawQuery; got != "keep=1" {
|
||||||
|
t.Fatalf("fail-closed hook kept query %q, want token parameter stripped", got)
|
||||||
|
}
|
||||||
|
if got := request.Header.Get("Cookie"); got != "sid=abc" {
|
||||||
|
t.Fatalf("fail-closed hook kept cookie %q, want nz-jwt pair stripped", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
Whitespace-only changes.
@@ -1,9 +1,9 @@
|
|||||||
module github.com/nezhahq/nezha
|
module github.com/nezhahq/nezha
|
||||||
|
|
||||||
go 1.26.5
|
go 1.26.10
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/VictoriaMetrics/VictoriaMetrics v1.148.0
|
github.com/VictoriaMetrics/VictoriaMetrics v1.151.0
|
||||||
github.com/appleboy/gin-jwt/v2 v2.10.3
|
github.com/appleboy/gin-jwt/v2 v2.10.3
|
||||||
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
|
github.com/dustinkirkland/golang-petname v0.0.0-20260215035315-f0c533e9ce9b
|
||||||
github.com/gin-contrib/pprof v1.5.4
|
github.com/gin-contrib/pprof v1.5.4
|
||||||
@@ -14,18 +14,18 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.3
|
github.com/gorilla/websocket v1.5.3
|
||||||
github.com/hashicorp/go-uuid v1.0.3
|
github.com/hashicorp/go-uuid v1.0.3
|
||||||
github.com/jinzhu/copier v0.4.0
|
github.com/jinzhu/copier v0.4.0
|
||||||
github.com/knadh/koanf/maps v0.1.2
|
github.com/knadh/koanf/maps v0.1.3
|
||||||
github.com/knadh/koanf/providers/env v1.1.0
|
github.com/knadh/koanf/providers/env v1.1.0
|
||||||
github.com/knadh/koanf/providers/file v1.2.1
|
github.com/knadh/koanf/providers/file v1.2.1
|
||||||
github.com/knadh/koanf/v2 v2.3.5
|
github.com/knadh/koanf/v2 v2.3.6
|
||||||
github.com/leonelquinteros/gotext v1.7.2
|
github.com/leonelquinteros/gotext v1.7.2
|
||||||
github.com/libdns/cloudflare v0.2.2
|
github.com/libdns/cloudflare v0.2.2
|
||||||
github.com/libdns/he v1.2.2
|
github.com/libdns/he v1.2.2
|
||||||
github.com/libdns/libdns v1.1.1
|
github.com/libdns/libdns v1.1.1
|
||||||
github.com/likexian/whois v1.15.7
|
github.com/likexian/whois v1.15.7
|
||||||
github.com/likexian/whois-parser v1.24.21
|
github.com/likexian/whois-parser v1.24.21
|
||||||
github.com/mattn/go-sqlite3 v1.14.49
|
github.com/mattn/go-sqlite3 v1.14.50
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.73
|
||||||
github.com/modelcontextprotocol/go-sdk v1.7.0
|
github.com/modelcontextprotocol/go-sdk v1.7.0
|
||||||
github.com/nezhahq/libdns-tencentcloud v0.0.0-20260628095405-2ab294ec675b
|
github.com/nezhahq/libdns-tencentcloud v0.0.0-20260628095405-2ab294ec675b
|
||||||
github.com/ory/graceful v0.2.0
|
github.com/ory/graceful v0.2.0
|
||||||
@@ -33,20 +33,20 @@ require (
|
|||||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||||
github.com/robfig/cron/v3 v3.0.1
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
github.com/sqids/sqids-go v0.4.1
|
github.com/sqids/sqids-go v0.4.1
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.12.1
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.1
|
github.com/swaggo/gin-swagger v1.6.1
|
||||||
github.com/swaggo/swag v1.16.6
|
github.com/swaggo/swag v1.16.6
|
||||||
github.com/tidwall/gjson v1.19.0
|
github.com/tidwall/gjson v1.19.0
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.55.0
|
||||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa
|
||||||
golang.org/x/mod v0.38.0
|
golang.org/x/mod v0.40.0
|
||||||
golang.org/x/net v0.57.0
|
golang.org/x/net v0.58.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.22.0
|
golang.org/x/sync v0.22.0
|
||||||
golang.org/x/sys v0.47.0
|
golang.org/x/sys v0.47.0
|
||||||
google.golang.org/grpc v1.83.0
|
google.golang.org/grpc v1.83.2
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
|
google.golang.org/protobuf v1.36.12
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/datatypes v1.2.7
|
||||||
gorm.io/driver/sqlite v1.6.0
|
gorm.io/driver/sqlite v1.6.0
|
||||||
@@ -66,7 +66,6 @@ require (
|
|||||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
|
||||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
@@ -91,18 +90,17 @@ require (
|
|||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/compress v1.18.6 // indirect
|
github.com/klauspost/compress v1.19.1 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/likexian/gokit v0.25.16 // indirect
|
github.com/likexian/gokit v0.25.16 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.23 // indirect
|
||||||
github.com/mitchellh/copystructure v1.2.0 // indirect
|
github.com/mitchellh/copystructure v1.2.0 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||||
@@ -115,18 +113,18 @@ require (
|
|||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
github.com/valyala/fastrand v1.1.0 // indirect
|
github.com/valyala/fastrand v1.1.0 // indirect
|
||||||
github.com/valyala/gozstd v1.24.0 // indirect
|
github.com/valyala/gozstd v1.25.0 // indirect
|
||||||
github.com/valyala/histogram v1.2.0 // indirect
|
github.com/valyala/histogram v1.2.0 // indirect
|
||||||
github.com/valyala/quicktemplate v1.8.0 // indirect
|
github.com/valyala/quicktemplate v1.8.0 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
golang.org/x/arch v0.27.0 // indirect
|
golang.org/x/arch v0.27.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.41.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.48.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 // indirect
|
|
||||||
gorm.io/driver/mysql v1.5.6 // indirect
|
gorm.io/driver/mysql v1.5.6 // indirect
|
||||||
|
golang.org/x/tools v0.49.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a // indirect
|
||||||
)
|
)
|
||||||
@@ -2,8 +2,8 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||||
github.com/VictoriaMetrics/VictoriaMetrics v1.148.0 h1:qfsMLjvkNnIIE3w3o7Sf1ejpYAE3pGwqAz4OqKNqZYI=
|
github.com/VictoriaMetrics/VictoriaMetrics v1.151.0 h1:Svzb/7gPYbzmQnvLjab8h1Jl9olB76mokDE7ykvF/ro=
|
||||||
github.com/VictoriaMetrics/VictoriaMetrics v1.148.0/go.mod h1:/grcppu6T0Y1k8z6e7ACb43sYUKGkvQ4xVVdCANK/d0=
|
github.com/VictoriaMetrics/VictoriaMetrics v1.151.0/go.mod h1:Mp7qEBDOXwP50gC93cF8wJmaGjTFSJP2WA6dRtMI7qU=
|
||||||
github.com/VictoriaMetrics/easyproto v1.2.0 h1:FJT9uNXA2isppFuJErbLqD306KoFlehl7Wn2dg/6oIE=
|
github.com/VictoriaMetrics/easyproto v1.2.0 h1:FJT9uNXA2isppFuJErbLqD306KoFlehl7Wn2dg/6oIE=
|
||||||
github.com/VictoriaMetrics/easyproto v1.2.0/go.mod h1:QlGlzaJnDfFd8Lk6Ci/fuLxfTo3/GThPs2KH23mv710=
|
github.com/VictoriaMetrics/easyproto v1.2.0/go.mod h1:QlGlzaJnDfFd8Lk6Ci/fuLxfTo3/GThPs2KH23mv710=
|
||||||
github.com/VictoriaMetrics/fastcache v1.13.3 h1:rBabE0iIxcqKEMCwUmwHZ9dgEqXerg8FRbRDUvC7OVc=
|
github.com/VictoriaMetrics/fastcache v1.13.3 h1:rBabE0iIxcqKEMCwUmwHZ9dgEqXerg8FRbRDUvC7OVc=
|
||||||
@@ -46,8 +46,8 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko
|
|||||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4=
|
||||||
@@ -133,18 +133,18 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
|||||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
github.com/knadh/koanf/maps v0.1.3 h1:P1z7EvTqdFBrPYbzSvorvrpib+sjkUMxf0FVvA5NKK4=
|
||||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
github.com/knadh/koanf/maps v0.1.3/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||||
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
|
github.com/knadh/koanf/providers/env v1.1.0 h1:U2VXPY0f+CsNDkvdsG8GcsnK4ah85WwWyJgef9oQMSc=
|
||||||
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
|
github.com/knadh/koanf/providers/env v1.1.0/go.mod h1:QhHHHZ87h9JxJAn2czdEl6pdkNnDh/JS1Vtsyt65hTY=
|
||||||
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
|
github.com/knadh/koanf/providers/file v1.2.1 h1:bEWbtQwYrA+W2DtdBrQWyXqJaJSG3KrP3AESOJYp9wM=
|
||||||
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
github.com/knadh/koanf/providers/file v1.2.1/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
|
||||||
github.com/knadh/koanf/v2 v2.3.5 h1:2dXJUYaKGm4SGYeoAtBviq9+02JZo/pxQ2ssOd60rJg=
|
github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
|
||||||
github.com/knadh/koanf/v2 v2.3.5/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -167,12 +167,18 @@ github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpO
|
|||||||
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
|
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
|
github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyiQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
github.com/mattn/go-sqlite3 v1.14.49 h1:B8jBHC3xhxZgxztrgruTuLucebnULQnx4W7cF7SAE9w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
github.com/mattn/go-sqlite3 v1.14.49/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.50 h1:dmdFvo1XG4MPzA4IkAmE9upVz/Nj31uRoM5+jC8hYbY=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.50/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
|
||||||
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA=
|
||||||
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA=
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
|
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||||
|
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
@@ -198,8 +204,6 @@ github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
|
|||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||||
@@ -223,8 +227,8 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
|
||||||
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
|
||||||
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
github.com/swaggo/gin-swagger v1.6.1 h1:Ri06G4gc9N4t4k8hekMigJ9zKTFSlqj/9paAQCQs7cY=
|
||||||
@@ -250,8 +254,8 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw
|
|||||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||||
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
github.com/valyala/fastrand v1.1.0 h1:f+5HkLW4rsgzdNoleUOB69hyT9IlD2ZQh9GyDMfb5G8=
|
||||||
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
github.com/valyala/fastrand v1.1.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
|
||||||
github.com/valyala/gozstd v1.24.0 h1:M/9L3h7bVwbj2gZwrmuoaxzwVrmBUvos2jG9cZtuhlc=
|
github.com/valyala/gozstd v1.25.0 h1:7gS7+5zwidZT1BFQqGPAPII8ekZ3tvYTp5IvOEWC34Y=
|
||||||
github.com/valyala/gozstd v1.24.0/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
github.com/valyala/gozstd v1.25.0/go.mod h1:y5Ew47GLlP37EkTB+B4s7r6A5rdaeB7ftbl9zoYiIPQ=
|
||||||
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
|
github.com/valyala/histogram v1.2.0 h1:wyYGAZZt3CpwUiIb9AU/Zbllg1llXyrtApRS815OLoQ=
|
||||||
github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY=
|
github.com/valyala/histogram v1.2.0/go.mod h1:Hb4kBwb4UxsaNbbbh+RRz8ZR6pdodR57tzWUS3BUzXY=
|
||||||
github.com/valyala/quicktemplate v1.8.0 h1:zU0tjbIqTRgKQzFY1L42zq0qR3eh4WoQQdIdqCysW5k=
|
github.com/valyala/quicktemplate v1.8.0 h1:zU0tjbIqTRgKQzFY1L42zq0qR3eh4WoQQdIdqCysW5k=
|
||||||
@@ -279,25 +283,25 @@ go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
|||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM=
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa h1:QSyA8ishJCyT21kER9KwNt0b7BM3iRK4x9QXhjN5Fdk=
|
||||||
golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q=
|
golang.org/x/exp v0.0.0-20260824195058-e88cd73687aa/go.mod h1:zeBbvyFKDaLwa7CH/zI8KXt7gTl14SF7sO08Pl5jBCM=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
|
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||||
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
|
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE=
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU=
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -319,24 +323,24 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
|||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
|
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
||||||
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
|
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324 h1:9HZDLIdYBJXAnaFOr9WHrKVycfpY+75s9HGadC0305A=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a h1:qI/YMH1ep2qQtqcp00gMQyoU7mjvbhg88GJKCvfoLj0=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260615183401-62b3387ff324/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260720211330-0afa2a65878a/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ=
|
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||||
google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
|||||||
@@ -169,7 +169,7 @@ func requireCheckoutAndSetupGo(t *testing.T, steps []qualityStep) {
|
|||||||
require.False(t, *checkout.With.PersistCredentials)
|
require.False(t, *checkout.With.PersistCredentials)
|
||||||
setupGo := steps[1]
|
setupGo := steps[1]
|
||||||
require.Equal(t, "actions/setup-go@v7", setupGo.Uses)
|
require.Equal(t, "actions/setup-go@v7", setupGo.Uses)
|
||||||
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
|
require.Equal(t, "^1.26.10", setupGo.With.GoVersion)
|
||||||
require.NotNil(t, setupGo.With.Cache)
|
require.NotNil(t, setupGo.With.Cache)
|
||||||
require.False(t, *setupGo.With.Cache)
|
require.False(t, *setupGo.With.Cache)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func TestPolicy_NezhaStressWorkflowRunsCrossRepositoryTest(t *testing.T) {
|
|||||||
|
|
||||||
setupGo := stressJob.stepNamed(t, "Set up Go")
|
setupGo := stressJob.stepNamed(t, "Set up Go")
|
||||||
require.Equal(t, "actions/setup-go@v7", setupGo.Uses)
|
require.Equal(t, "actions/setup-go@v7", setupGo.Uses)
|
||||||
require.Equal(t, "1.26.x", setupGo.With.GoVersion)
|
require.Equal(t, "^1.26.10", setupGo.With.GoVersion)
|
||||||
require.False(t, *setupGo.With.Cache)
|
require.False(t, *setupGo.With.Cache)
|
||||||
|
|
||||||
prepareDashboardInputs := stressJob.stepNamed(t, "Prepare Dashboard build inputs")
|
prepareDashboardInputs := stressJob.stepNamed(t, "Prepare Dashboard build inputs")
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAlertEvaluationDistinguishesUnknownFromBreach(t *testing.T) {
|
||||||
|
server := &Server{Common: Common{ID: 1}}
|
||||||
|
rule := &Rule{Type: "cpu", Max: 80, Cover: RuleCoverAll}
|
||||||
|
if passed, known := rule.Evaluate(nil, server, nil); !passed || known {
|
||||||
|
t.Fatalf("missing state = (%v, %v), want unknown", passed, known)
|
||||||
|
}
|
||||||
|
lease := server.AttachStateStream(runtimeOwnershipStream{})
|
||||||
|
if !lease.UpdateState(&HostState{CPU: 99}, time.Now()) {
|
||||||
|
t.Fatal("state update rejected")
|
||||||
|
}
|
||||||
|
if passed, known := rule.Evaluate(nil, server, nil); passed || !known {
|
||||||
|
t.Fatalf("fresh high CPU = (%v, %v), want known breach", passed, known)
|
||||||
|
}
|
||||||
|
if !lease.Clear() {
|
||||||
|
t.Fatal("stream clear rejected")
|
||||||
|
}
|
||||||
|
// The raw metric remains available for historical display. The sentinel
|
||||||
|
// enforces freshness and must not count this value after disconnect.
|
||||||
|
if !server.RuntimeSnapshot().LastActive.IsZero() {
|
||||||
|
t.Fatal("disconnect did not clear online visibility")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCycleTestDB(t *testing.T, migrate bool) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if migrate {
|
||||||
|
if err := db.AutoMigrate(&Transfer{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func newCycleTestStats() *CycleTransferStats {
|
||||||
|
return &CycleTransferStats{
|
||||||
|
ServerName: make(map[uint64]string),
|
||||||
|
Transfer: make(map[uint64]uint64),
|
||||||
|
NextUpdate: make(map[uint64]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCycleRuleRejectsInvalidConfiguration(t *testing.T) {
|
||||||
|
start := time.Now().Add(-time.Hour)
|
||||||
|
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 100}
|
||||||
|
if !rule.HasSafeCycleConfiguration() {
|
||||||
|
t.Fatal("normal hourly cycle rejected")
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mutate func(*Rule)
|
||||||
|
}{
|
||||||
|
{"unknown unit", func(r *Rule) { r.CycleUnit = "fortnight" }},
|
||||||
|
{"negative limit", func(r *Rule) { r.Max = -1 }},
|
||||||
|
{"NaN limit", func(r *Rule) { r.Max = math.NaN() }},
|
||||||
|
{"reversed limits", func(r *Rule) { r.Min, r.Max = 200, 100 }},
|
||||||
|
{"future start", func(r *Rule) { future := time.Now().Add(time.Hour); r.CycleStart = &future }},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
copy := *rule
|
||||||
|
tc.mutate(©)
|
||||||
|
if copy.HasSafeCycleConfiguration() {
|
||||||
|
t.Fatal("unsafe cycle accepted")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCycleBoundsUseOneClockAtBoundary(t *testing.T) {
|
||||||
|
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 100}
|
||||||
|
before := start.Add(59*time.Minute + 59*time.Second)
|
||||||
|
from, to := rule.transferDurationBounds(before)
|
||||||
|
if !from.Equal(start) || !to.Equal(start.Add(time.Hour)) {
|
||||||
|
t.Fatalf("before boundary = %v..%v", from, to)
|
||||||
|
}
|
||||||
|
from, to = rule.transferDurationBounds(start.Add(time.Hour))
|
||||||
|
if !from.Equal(start.Add(time.Hour)) || !to.Equal(start.Add(2*time.Hour)) {
|
||||||
|
t.Fatalf("at boundary = %v..%v", from, to)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCycleQueryFailureIsUnknownAndDoesNotCache(t *testing.T) {
|
||||||
|
start := time.Now().Add(-time.Hour)
|
||||||
|
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 50}
|
||||||
|
server := &Server{Common: Common{ID: 1}, State: &HostState{}}
|
||||||
|
passed, known := rule.Evaluate(newCycleTestStats(), server, newCycleTestDB(t, false))
|
||||||
|
if !passed || known || len(rule.NextTransferAt) != 0 {
|
||||||
|
t.Fatalf("SQL error yielded passed=%v known=%v cache=%v", passed, known, rule.NextTransferAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCycleQueryExcludesFutureRowsAndHandlesMinOnly(t *testing.T) {
|
||||||
|
start := time.Now().Add(-90 * time.Minute)
|
||||||
|
db := newCycleTestDB(t, true)
|
||||||
|
server := &Server{Common: Common{ID: 1}, State: &HostState{}}
|
||||||
|
for _, row := range []Transfer{
|
||||||
|
{Common: Common{CreatedAt: time.Now().Add(-time.Minute)}, ServerID: 1, Out: 10},
|
||||||
|
{Common: Common{CreatedAt: time.Now().Add(10 * time.Minute)}, ServerID: 1, Out: 100},
|
||||||
|
} {
|
||||||
|
if err := db.Create(&row).Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rule := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Max: 50}
|
||||||
|
stats := newCycleTestStats()
|
||||||
|
passed, known := rule.Evaluate(stats, server, db)
|
||||||
|
if !passed || !known || stats.Transfer[1] != 10 {
|
||||||
|
t.Fatalf("future row included: passed=%v known=%v amount=%d", passed, known, stats.Transfer[1])
|
||||||
|
}
|
||||||
|
minOnly := &Rule{Type: "transfer_out_cycle", CycleStart: &start, CycleInterval: 1, CycleUnit: "hour", Min: 20}
|
||||||
|
passed, known = minOnly.Evaluate(newCycleTestStats(), server, db)
|
||||||
|
if passed || !known {
|
||||||
|
t.Fatalf("min-only cycle = (%v,%v), want known breach", passed, known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertEvaluationAllNetworkSpeedUsesBothDirections(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
in, out uint64
|
||||||
|
breach bool
|
||||||
|
}{
|
||||||
|
{"inbound only", 100, 0, true},
|
||||||
|
{"outbound below combined limit", 0, 30, false},
|
||||||
|
{"both directions", 25, 30, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := &Server{Common: Common{ID: 1}, State: &HostState{NetInSpeed: tc.in, NetOutSpeed: tc.out}}
|
||||||
|
rule := &Rule{Type: "net_all_speed", Max: 50, Cover: RuleCoverAll}
|
||||||
|
passed, known := rule.Evaluate(nil, server, nil)
|
||||||
|
if !known || passed == tc.breach {
|
||||||
|
t.Fatalf("got passed=%v known=%v, want breach=%v", passed, known, tc.breach)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertEvaluationUnavailableSensorsAreUnknown(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule string
|
||||||
|
state *HostState
|
||||||
|
host *Host
|
||||||
|
}{
|
||||||
|
{"missing memory host", "memory", &HostState{MemUsed: 1}, nil},
|
||||||
|
{"zero memory total", "memory", &HostState{MemUsed: 1}, &Host{}},
|
||||||
|
{"missing GPU", "gpu_max", &HostState{}, &Host{}},
|
||||||
|
{"missing temperature", "temperature_max", &HostState{}, &Host{}},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
server := &Server{Common: Common{ID: 1}, State: tc.state, Host: tc.host}
|
||||||
|
passed, known := (&Rule{Type: tc.rule, Max: 50, Cover: RuleCoverAll}).Evaluate(nil, server, nil)
|
||||||
|
if !passed || known {
|
||||||
|
t.Fatalf("got passed=%v known=%v, want unknown", passed, known)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOfflineRuleDoesNotRequireMetricState(t *testing.T) {
|
||||||
|
server := &Server{Common: Common{ID: 1}}
|
||||||
|
InitServer(server)
|
||||||
|
passed, known := (&Rule{Type: "offline", Cover: RuleCoverAll}).Evaluate(nil, server, nil)
|
||||||
|
if passed || !known {
|
||||||
|
t.Fatalf("offline without state = (%v, %v), want known breach", passed, known)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompoundAlertUsesOneImmutableAgentReport(t *testing.T) {
|
||||||
|
server := &Server{Common: Common{ID: 1}}
|
||||||
|
alert := &AlertRule{Rules: []*Rule{
|
||||||
|
{Type: "cpu", Max: 80, Cover: RuleCoverAll},
|
||||||
|
{Type: "memory", Max: 80, Cover: RuleCoverAll},
|
||||||
|
}}
|
||||||
|
oldReport := RuntimeSnapshot{
|
||||||
|
State: &HostState{CPU: 99, MemUsed: 0},
|
||||||
|
Host: &Host{MemTotal: 100},
|
||||||
|
}
|
||||||
|
newReport := RuntimeSnapshot{
|
||||||
|
State: &HostState{CPU: 0, MemUsed: 99},
|
||||||
|
Host: &Host{MemTotal: 100},
|
||||||
|
}
|
||||||
|
for _, report := range []RuntimeSnapshot{oldReport, newReport} {
|
||||||
|
point, known := alert.SnapshotStatusWithRuntime(nil, server, report, nil)
|
||||||
|
if !known || len(point) != 2 || point[0] == point[1] {
|
||||||
|
t.Fatalf("compound point combined incompatible reports: known=%v point=%v", known, point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func timedRule(kind string, duration uint64) *AlertRule {
|
||||||
|
return &AlertRule{Rules: []*Rule{{Type: kind, Duration: duration, Max: 80, Cover: RuleCoverAll}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timedPoints(start time.Time, values []bool, spacing time.Duration) []TimedAlertPoint {
|
||||||
|
points := make([]TimedAlertPoint, len(values))
|
||||||
|
for i, value := range values {
|
||||||
|
points[i] = TimedAlertPoint{At: start.Add(time.Duration(i) * spacing), Values: []bool{value}}
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedAlertNeedsElapsedWindowAndDistinctReports(t *testing.T) {
|
||||||
|
start := time.Unix(1_700_000_000, 0)
|
||||||
|
rule := timedRule("cpu", 5)
|
||||||
|
points := timedPoints(start, []bool{false, false, false, false}, AlertSampleInterval)
|
||||||
|
if known, _ := rule.CheckTimed(points, points[len(points)-1].At); known {
|
||||||
|
t.Fatal("four reports decided a five-tick window")
|
||||||
|
}
|
||||||
|
points = append(points, TimedAlertPoint{At: start.Add(12 * time.Second), Values: []bool{false}})
|
||||||
|
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || passed {
|
||||||
|
t.Fatalf("complete high-CPU window: known=%v passed=%v", known, passed)
|
||||||
|
}
|
||||||
|
// Repeating the same observation does not advance wall-clock coverage.
|
||||||
|
if known, _ := rule.CheckTimed(points, start.Add(30*time.Second)); known {
|
||||||
|
t.Fatal("stale sample created a new complete window")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedAlertGapIsUnknownAndCannotResolve(t *testing.T) {
|
||||||
|
start := time.Unix(1_700_000_000, 0)
|
||||||
|
rule := timedRule("cpu", 5)
|
||||||
|
points := []TimedAlertPoint{
|
||||||
|
{At: start, Values: []bool{false}},
|
||||||
|
{At: start.Add(30 * time.Second), Values: []bool{true}},
|
||||||
|
}
|
||||||
|
if known, _ := rule.CheckTimed(points, start.Add(30*time.Second)); known {
|
||||||
|
t.Fatal("telemetry gap must not be interpreted as healthy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedAlertIrregularReportsUseElapsedTime(t *testing.T) {
|
||||||
|
start := time.Unix(1_700_000_000, 0)
|
||||||
|
rule := timedRule("cpu", 6)
|
||||||
|
points := timedPoints(start, []bool{false, false, false, false}, 5*time.Second)
|
||||||
|
if known, passed := rule.CheckTimed(points, start.Add(15*time.Second)); !known || passed {
|
||||||
|
t.Fatalf("irregular reports cover the wall-clock window: known=%v passed=%v", known, passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedAlertExactSeventyPercentIsNotIncident(t *testing.T) {
|
||||||
|
start := time.Unix(1_700_000_000, 0)
|
||||||
|
rule := timedRule("cpu", 10)
|
||||||
|
values := []bool{false, false, false, false, false, false, false, true, true, true}
|
||||||
|
points := timedPoints(start, values, AlertSampleInterval)
|
||||||
|
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || !passed {
|
||||||
|
t.Fatalf("70%% failed is below the >70%% trigger: known=%v passed=%v", known, passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedAlertTenTickWindowDoesNotDecideAtNine(t *testing.T) {
|
||||||
|
start := time.Unix(1_700_000_000, 0)
|
||||||
|
rule := timedRule("cpu", 10)
|
||||||
|
points := timedPoints(start, []bool{false, false, false, false, false, false, false, false, false}, AlertSampleInterval)
|
||||||
|
if known, _ := rule.CheckTimed(points, points[len(points)-1].At); known {
|
||||||
|
t.Fatal("nine reports decided a ten-tick window one tick early")
|
||||||
|
}
|
||||||
|
points = append(points, TimedAlertPoint{At: start.Add(27 * time.Second), Values: []bool{false}})
|
||||||
|
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || passed {
|
||||||
|
t.Fatalf("full ten-tick breach: known=%v passed=%v", known, passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedOfflineRequiresSustainedAbsence(t *testing.T) {
|
||||||
|
start := time.Unix(1_700_000_000, 0)
|
||||||
|
rule := timedRule("offline", 5)
|
||||||
|
points := timedPoints(start, []bool{false, false, false, false, false}, AlertSampleInterval)
|
||||||
|
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || passed {
|
||||||
|
t.Fatalf("continuous offline interval: known=%v passed=%v", known, passed)
|
||||||
|
}
|
||||||
|
points = append(points, TimedAlertPoint{At: start.Add(15 * time.Second), Values: []bool{true}})
|
||||||
|
if known, passed := rule.CheckTimed(points, points[len(points)-1].At); !known || !passed {
|
||||||
|
t.Fatalf("online observation should resolve offline: known=%v passed=%v", known, passed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTimedCompoundRuleWaitsForAllKnown(t *testing.T) {
|
||||||
|
start := time.Unix(1_700_000_000, 0)
|
||||||
|
rule := &AlertRule{Rules: []*Rule{
|
||||||
|
{Type: "cpu", Duration: 5, Max: 80, Cover: RuleCoverAll},
|
||||||
|
{Type: "memory", Duration: 5, Max: 80, Cover: RuleCoverAll},
|
||||||
|
}}
|
||||||
|
points := timedPoints(start, []bool{false, false, false, false, false}, AlertSampleInterval)
|
||||||
|
if known, _ := rule.CheckTimed(points, points[len(points)-1].At); known {
|
||||||
|
t.Fatal("missing second condition treated as decisive")
|
||||||
|
}
|
||||||
|
}
|
||||||
+212
-14
@@ -2,12 +2,134 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"slices"
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
AlertSampleInterval = 3 * time.Second
|
||||||
|
AlertSampleMaxAge = 6 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// TimedAlertPoint records one distinct observation. Its timestamp is the
|
||||||
|
// dashboard receipt time of the Agent report, or the sentinel tick for an
|
||||||
|
// offline-only rule.
|
||||||
|
type TimedAlertPoint struct {
|
||||||
|
At time.Time
|
||||||
|
Values []bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckTimed evaluates the legacy Duration as Duration * 3 seconds, avoiding
|
||||||
|
// both repeated stale samples and cadence-dependent early incidents. Unknown
|
||||||
|
// coverage cannot create an incident or a recovery.
|
||||||
|
func (r *AlertRule) CheckTimed(points []TimedAlertPoint, now time.Time) (known, passed bool) {
|
||||||
|
if r == nil || len(r.Rules) == 0 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
allFailed := true
|
||||||
|
for index, rule := range r.Rules {
|
||||||
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
ruleKnown, rulePassed := rule.checkTimed(points, index, now)
|
||||||
|
if !ruleKnown {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if rulePassed {
|
||||||
|
allFailed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, !allFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rule *Rule) checkTimed(points []TimedAlertPoint, index int, now time.Time) (known, passed bool) {
|
||||||
|
if len(points) == 0 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if rule.IsTransferDurationRule() {
|
||||||
|
last := points[len(points)-1]
|
||||||
|
if index >= len(last.Values) || now.Sub(last.At) > AlertSampleMaxAge {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
return true, last.Values[index]
|
||||||
|
}
|
||||||
|
window := time.Duration(rule.Duration) * AlertSampleInterval
|
||||||
|
if window <= 0 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
end := now.Add(AlertSampleInterval) // the current observation represents one tick
|
||||||
|
start := end.Add(-window)
|
||||||
|
if points[0].At.After(start) {
|
||||||
|
return false, false // the configured wall-clock window has not elapsed
|
||||||
|
}
|
||||||
|
var covered, failed time.Duration
|
||||||
|
for i, point := range points {
|
||||||
|
if index >= len(point.Values) {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
segmentStart := point.At
|
||||||
|
if start.After(segmentStart) {
|
||||||
|
segmentStart = start
|
||||||
|
}
|
||||||
|
segmentEnd := point.At.Add(AlertSampleMaxAge)
|
||||||
|
if end.Before(segmentEnd) {
|
||||||
|
segmentEnd = end
|
||||||
|
}
|
||||||
|
if i+1 < len(points) {
|
||||||
|
if points[i+1].At.Before(segmentEnd) {
|
||||||
|
segmentEnd = points[i+1].At
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if tickEnd := point.At.Add(AlertSampleInterval); tickEnd.Before(segmentEnd) {
|
||||||
|
segmentEnd = tickEnd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !segmentEnd.After(segmentStart) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
span := segmentEnd.Sub(segmentStart)
|
||||||
|
covered += span
|
||||||
|
if !point.Values[index] {
|
||||||
|
failed += span
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if covered < window-window/10 {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if rule.IsOfflineRule() {
|
||||||
|
if covered-failed >= AlertSampleInterval {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
if failed >= window-AlertSampleInterval && covered >= window-AlertSampleInterval {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if float64(failed) > float64(window)*0.70 {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
if float64(failed+window-covered) <= float64(window)*0.70 {
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *AlertRule) TimedRetention() time.Duration {
|
||||||
|
longest := AlertSampleMaxAge
|
||||||
|
for _, rule := range r.Rules {
|
||||||
|
if rule == nil || rule.IsTransferDurationRule() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if duration, ok := rule.DurationInt(); ok {
|
||||||
|
longest = max(longest, time.Duration(duration)*AlertSampleInterval+AlertSampleMaxAge)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return longest
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ModeAlwaysTrigger = 0
|
ModeAlwaysTrigger = 0
|
||||||
ModeOnetimeTrigger = 1
|
ModeOnetimeTrigger = 1
|
||||||
@@ -64,6 +186,39 @@ func (r *AlertRule) Enabled() bool {
|
|||||||
return r.Enable != nil && *r.Enable
|
return r.Enable != nil && *r.Enable
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsSafeToEvaluate validates persisted rule data before it reaches the alert
|
||||||
|
// goroutine. API validation protects new writes; this second boundary protects
|
||||||
|
// upgrades from malformed or deliberately poisoned rows already in the DB.
|
||||||
|
func (r *AlertRule) IsSafeToEvaluate() bool {
|
||||||
|
if r == nil || len(r.Rules) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, rule := range r.Rules {
|
||||||
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if !rule.HasSafeThresholds() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch rule.Cover {
|
||||||
|
case RuleCoverAll, RuleCoverIgnoreAll:
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if rule.IsTransferDurationRule() {
|
||||||
|
if !rule.HasSafeCycleConfiguration() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
duration, ok := rule.DurationInt()
|
||||||
|
if !ok || duration < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// HasPermission extends the default owner/admin check with PAT
|
// HasPermission extends the default owner/admin check with PAT
|
||||||
// server_ids whitelist enforcement. AlertRule.Snapshot fans out across
|
// server_ids whitelist enforcement. AlertRule.Snapshot fans out across
|
||||||
// every owner-visible server filtered only by Rule.Ignore semantics
|
// every owner-visible server filtered only by Rule.Ignore semantics
|
||||||
@@ -122,22 +277,52 @@ func (r *AlertRule) HasPermission(ctx *gin.Context) bool {
|
|||||||
|
|
||||||
// Snapshot 对传入的Server进行该报警规则下所有type的检查 返回每项检查结果
|
// Snapshot 对传入的Server进行该报警规则下所有type的检查 返回每项检查结果
|
||||||
func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []bool {
|
func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []bool {
|
||||||
|
point, _ := r.SnapshotStatus(cycleTransferStats, server, db)
|
||||||
|
return point
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotStatus preserves the distinction between a healthy measurement and
|
||||||
|
// telemetry that is unavailable. A compound alert cannot be decided while any
|
||||||
|
// of its applicable conditions is unknown.
|
||||||
|
func (r *AlertRule) SnapshotStatus(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) ([]bool, bool) {
|
||||||
|
if server == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return r.SnapshotStatusWithRuntime(cycleTransferStats, server, server.RuntimeSnapshot(), db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotStatusWithRuntime uses one immutable report for every condition.
|
||||||
|
func (r *AlertRule) SnapshotStatusWithRuntime(cycleTransferStats *CycleTransferStats, server *Server, runtime RuntimeSnapshot, db *gorm.DB) ([]bool, bool) {
|
||||||
point := make([]bool, len(r.Rules))
|
point := make([]bool, len(r.Rules))
|
||||||
|
known := true
|
||||||
|
|
||||||
for i, rule := range r.Rules {
|
for i, rule := range r.Rules {
|
||||||
point[i] = rule.Snapshot(cycleTransferStats, server, db)
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
// Invalid persisted rules are ignored instead of being interpreted as
|
||||||
|
// a failed condition or allowed to panic the sentinel.
|
||||||
|
point[i] = true
|
||||||
|
known = false
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
return point
|
var valid bool
|
||||||
|
point[i], valid = rule.EvaluateRuntime(cycleTransferStats, server, runtime, db)
|
||||||
|
known = known && valid
|
||||||
|
}
|
||||||
|
return point, known
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 传入包含当前报警规则下所有type检查结果 返回报警持续时间与是否通过报警检查(通过则返回true)
|
// Check 传入包含当前报警规则下所有type检查结果 返回报警持续时间与是否通过报警检查(通过则返回true)
|
||||||
func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
||||||
var hasPassedRule bool
|
var hasPassedRule bool
|
||||||
durations := make([]int, len(r.Rules))
|
durations := make([]int, len(r.Rules))
|
||||||
|
validRules := 0
|
||||||
|
|
||||||
for ruleIndex, rule := range r.Rules {
|
for ruleIndex, rule := range r.Rules {
|
||||||
duration := int(rule.Duration)
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if rule.IsTransferDurationRule() {
|
if rule.IsTransferDurationRule() {
|
||||||
|
validRules++
|
||||||
// 循环区间流量报警
|
// 循环区间流量报警
|
||||||
if durations[ruleIndex] < 1 {
|
if durations[ruleIndex] < 1 {
|
||||||
durations[ruleIndex] = 1
|
durations[ruleIndex] = 1
|
||||||
@@ -146,10 +331,21 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// 只要最后一次检查超出了规则范围 就认为检查未通过
|
// 只要最后一次检查超出了规则范围 就认为检查未通过
|
||||||
if len(points) > 0 && points[len(points)-1][ruleIndex] {
|
if len(points) > 0 {
|
||||||
|
lastPoint := points[len(points)-1]
|
||||||
|
if ruleIndex >= len(lastPoint) || lastPoint[ruleIndex] {
|
||||||
hasPassedRule = true
|
hasPassedRule = true
|
||||||
}
|
}
|
||||||
} else if rule.IsOfflineRule() {
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
duration, ok := rule.DurationInt()
|
||||||
|
if !ok || duration <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validRules++
|
||||||
|
if rule.IsOfflineRule() {
|
||||||
// 离线报警,检查直到最后一次在线的离线采样点是否大于 duration
|
// 离线报警,检查直到最后一次在线的离线采样点是否大于 duration
|
||||||
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
||||||
continue
|
continue
|
||||||
@@ -157,7 +353,7 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
var fail int
|
var fail int
|
||||||
for _, point := range slices.Backward(points[len(points)-duration:]) {
|
for _, point := range slices.Backward(points[len(points)-duration:]) {
|
||||||
fail++
|
fail++
|
||||||
if point[ruleIndex] {
|
if ruleIndex >= len(point) || point[ruleIndex] {
|
||||||
hasPassedRule = true
|
hasPassedRule = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
@@ -168,11 +364,7 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
// 常规报警
|
// 常规报警
|
||||||
// duration<=0 是无意义的规则(持续 0 秒):直接跳过该规则,
|
// duration<=0 是无意义的规则(持续 0 秒):直接跳过该规则,
|
||||||
// 既不污染 hasPassedRule(否则会连带跳过同一 alert 里其它有效
|
// 既不污染 hasPassedRule(否则会连带跳过同一 alert 里其它有效
|
||||||
// 规则),也避免下方 fail*100/total 在 total=0 时整数除零 panic
|
// 规则),也避免下方百分比计算在 total=0 时除零。
|
||||||
// —— checkStatus 无 recover,一次 panic 会拖垮整个告警 goroutine。
|
|
||||||
if duration <= 0 {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -181,17 +373,20 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
}
|
}
|
||||||
total, fail := duration, 0
|
total, fail := duration, 0
|
||||||
for timeTick := len(points) - duration; timeTick < len(points); timeTick++ {
|
for timeTick := len(points) - duration; timeTick < len(points); timeTick++ {
|
||||||
if !points[timeTick][ruleIndex] {
|
if ruleIndex >= len(points[timeTick]) || !points[timeTick][ruleIndex] {
|
||||||
fail++
|
fail++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
|
// 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
|
||||||
if fail*100/total <= 70 {
|
if float64(fail)*100/float64(total) <= 70 {
|
||||||
hasPassedRule = true
|
hasPassedRule = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if validRules == 0 {
|
||||||
|
return 0, true
|
||||||
|
}
|
||||||
// 仅当所有检查均未通过时 才触发告警
|
// 仅当所有检查均未通过时 才触发告警
|
||||||
return slices.Max(durations), hasPassedRule
|
return slices.Max(durations), hasPassedRule
|
||||||
}
|
}
|
||||||
@@ -205,10 +400,13 @@ func (r *AlertRule) Check(points [][]bool) (int, bool) {
|
|||||||
func (r *AlertRule) RetentionWindow() int {
|
func (r *AlertRule) RetentionWindow() int {
|
||||||
window := 0
|
window := 0
|
||||||
for _, rule := range r.Rules {
|
for _, rule := range r.Rules {
|
||||||
|
if rule == nil || !rule.IsSupportedType() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var need int
|
var need int
|
||||||
if rule.IsTransferDurationRule() {
|
if rule.IsTransferDurationRule() {
|
||||||
need = 1
|
need = 1
|
||||||
} else if d := int(rule.Duration); d > 0 {
|
} else if d, ok := rule.DurationInt(); ok && d > 0 {
|
||||||
need = d
|
need = d
|
||||||
}
|
}
|
||||||
if need > window {
|
if need > window {
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuleSnapshotIgnoresEmptyMetricSamples(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
ruleType string
|
||||||
|
hostState *HostState
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "GPU list is empty",
|
||||||
|
ruleType: "gpu_max",
|
||||||
|
hostState: &HostState{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all temperature samples are filtered",
|
||||||
|
ruleType: "temperature_max",
|
||||||
|
hostState: &HostState{Temperatures: []SensorTemperature{
|
||||||
|
{Name: "coretemp", Temperature: 0},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
server := &Server{Common: Common{ID: 1}, State: test.hostState, Host: &Host{}}
|
||||||
|
rule := &Rule{Type: test.ruleType, Max: 1, Duration: 3, Cover: RuleCoverAll}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("Snapshot panicked for missing metric data: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if passed := rule.Snapshot(nil, server, nil); !passed {
|
||||||
|
t.Fatal("missing metric data must be ignored instead of treated as a failed threshold")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRuleCheckRejectsOverflowingPersistedDuration(t *testing.T) {
|
||||||
|
rule := &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "offline",
|
||||||
|
Duration: math.MaxUint64,
|
||||||
|
Cover: RuleCoverAll,
|
||||||
|
}}}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
t.Fatalf("Check panicked after uint64-to-int overflow: %v", recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
duration, passed := rule.Check([][]bool{{false}})
|
||||||
|
if duration != 0 || !passed {
|
||||||
|
t.Fatalf("invalid persisted duration must be ignored safely, got duration=%d passed=%v", duration, passed)
|
||||||
|
}
|
||||||
|
if window := rule.RetentionWindow(); window != 0 {
|
||||||
|
t.Fatalf("invalid persisted duration must not create a retention window, got %d", window)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRulePersistedDataSafety(t *testing.T) {
|
||||||
|
cycleStart := time.Now().Add(-time.Hour)
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rule *AlertRule
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "normal rule",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "cpu", Duration: 3, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "normal cycle rule",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "transfer_in_cycle", CycleStart: &cycleStart, CycleInterval: 1, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unknown type",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "attacker_controlled", Duration: 3, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "overflowing duration",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "offline", Duration: math.MaxUint64, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cycle without start",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{{
|
||||||
|
Type: "transfer_in_cycle", CycleInterval: 1, Cover: RuleCoverAll,
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil rule",
|
||||||
|
rule: &AlertRule{Rules: []*Rule{nil}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty rule list",
|
||||||
|
rule: &AlertRule{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if got := test.rule.IsSafeToEvaluate(); got != test.want {
|
||||||
|
t.Fatalf("IsSafeToEvaluate()=%v, want %v", got, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
-4
@@ -26,7 +26,7 @@ func testCycleRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
Type: "_cycle",
|
Type: "transfer_in_cycle",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -39,7 +39,7 @@ func testCycleRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
Type: "_cycle",
|
Type: "transfer_in_cycle",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -130,6 +130,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -143,6 +144,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -156,6 +158,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -169,6 +172,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -182,6 +186,7 @@ func testGeneralRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -210,6 +215,7 @@ func testCombinedRules(t *testing.T) {
|
|||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -227,6 +233,7 @@ func testCombinedRules(t *testing.T) {
|
|||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -240,6 +247,7 @@ func testCombinedRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -257,9 +265,11 @@ func testCombinedRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "memory",
|
||||||
Duration: 30,
|
Duration: 30,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -273,9 +283,11 @@ func testCombinedRules(t *testing.T) {
|
|||||||
rule: &AlertRule{
|
rule: &AlertRule{
|
||||||
Rules: []*Rule{
|
Rules: []*Rule{
|
||||||
{
|
{
|
||||||
|
Type: "cpu",
|
||||||
Duration: 10,
|
Duration: 10,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
Type: "memory",
|
||||||
Duration: 30,
|
Duration: 30,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -416,7 +428,7 @@ func TestAlertRule_RetentionWindow(t *testing.T) {
|
|||||||
{"zero duration only", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 0}}}, 0},
|
{"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},
|
{"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},
|
{"offline keeps Duration", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 30}}}, 30},
|
||||||
{"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "net_in_speed_cycle"}}}, 1},
|
{"cycle looks back one", &AlertRule{Rules: []*Rule{{Type: "transfer_in_cycle"}}}, 1},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
if got := c.rule.RetentionWindow(); got != c.want {
|
if got := c.rule.RetentionWindow(); got != c.want {
|
||||||
@@ -469,7 +481,7 @@ func TestAlertRule_CombinedRuleAccumulatesSamples(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"general3+general10", &AlertRule{Rules: []*Rule{{Type: "cpu", Duration: 3}, {Type: "memory", Duration: 10}}}, []bool{false, false}, 9, 10},
|
{"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},
|
{"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},
|
{"transfer+general8", &AlertRule{Rules: []*Rule{{Type: "transfer_in_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},
|
{"offline3+offline12", &AlertRule{Rules: []*Rule{{Type: "offline", Duration: 3}, {Type: "offline", Duration: 12}}}, []bool{false, false}, 11, 12},
|
||||||
}
|
}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
|
|||||||
+34
-1
@@ -66,6 +66,9 @@ type ConfigDashboard struct {
|
|||||||
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"`
|
AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"`
|
||||||
|
|
||||||
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
|
EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
|
||||||
|
// AllowJWTIPChange disables the IP binding check for browser login sessions.
|
||||||
|
// Keep this disabled by default so a stolen JWT remains bound to its issuing IP.
|
||||||
|
AllowJWTIPChange bool `koanf:"allow_jwt_ip_change" json:"allow_jwt_ip_change,omitempty"`
|
||||||
|
|
||||||
EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist)
|
EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist)
|
||||||
|
|
||||||
@@ -91,6 +94,10 @@ type ConfigDashboard struct {
|
|||||||
AdminEmail string `koanf:"admin_email" json:"admin_email,omitempty"`
|
AdminEmail string `koanf:"admin_email" json:"admin_email,omitempty"`
|
||||||
DomainExpiryNotificationDays string `koanf:"domain_expiry_notification_days" json:"domain_expiry_notification_days,omitempty"`
|
DomainExpiryNotificationDays string `koanf:"domain_expiry_notification_days" json:"domain_expiry_notification_days,omitempty"`
|
||||||
ServerExpiryNotificationDays string `koanf:"server_expiry_notification_days" json:"server_expiry_notification_days,omitempty"`
|
ServerExpiryNotificationDays string `koanf:"server_expiry_notification_days" json:"server_expiry_notification_days,omitempty"`
|
||||||
|
|
||||||
|
// OnlineStatsDays 设备在线时间段统计的保留与查询窗口(天)。
|
||||||
|
// 取值范围 [MinOnlineStatsDays, MaxOnlineStatsDays],默认 DefaultOnlineStatsDays。
|
||||||
|
OnlineStatsDays int `koanf:"online_stats_days" json:"online_stats_days,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -118,6 +125,10 @@ type Config struct {
|
|||||||
// SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。
|
// SettingResponse 按值拷贝 ConfigDashboard 触发 copylocks。
|
||||||
mcpEnabled atomic.Bool `koanf:"-" json:"-" yaml:"-"`
|
mcpEnabled atomic.Bool `koanf:"-" json:"-" yaml:"-"`
|
||||||
|
|
||||||
|
// jwtIPChangeAllowed mirrors AllowJWTIPChange for concurrent authentication
|
||||||
|
// and setting updates. Keep atomic fields outside ConfigDashboard.
|
||||||
|
jwtIPChangeAllowed atomic.Bool `koanf:"-" json:"-" yaml:"-"`
|
||||||
|
|
||||||
// oauth2 配置
|
// oauth2 配置
|
||||||
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
|
Oauth2 map[string]*Oauth2Config `koanf:"oauth2" json:"oauth2,omitempty"`
|
||||||
|
|
||||||
@@ -240,6 +251,15 @@ func (c *Config) Read(path string, frontendTemplates []FrontendTemplate) error {
|
|||||||
c.JWTTimeout = 1
|
c.JWTTimeout = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if c.OnlineStatsDays == 0 {
|
||||||
|
c.OnlineStatsDays = DefaultOnlineStatsDays
|
||||||
|
} else if c.OnlineStatsDays < MinOnlineStatsDays || c.OnlineStatsDays > MaxOnlineStatsDays {
|
||||||
|
log.Printf("NEZHA>> online_stats_days %d out of range [%d, %d], reset to %d", c.OnlineStatsDays, MinOnlineStatsDays, MaxOnlineStatsDays, DefaultOnlineStatsDays)
|
||||||
|
c.OnlineStatsDays = DefaultOnlineStatsDays
|
||||||
|
}
|
||||||
|
|
||||||
|
c.jwtIPChangeAllowed.Store(c.AllowJWTIPChange)
|
||||||
|
|
||||||
if c.AgentSecretKey == "" {
|
if c.AgentSecretKey == "" {
|
||||||
c.AgentSecretKey, err = utils.GenerateRandomString(32)
|
c.AgentSecretKey, err = utils.GenerateRandomString(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -267,6 +287,17 @@ func (c *Config) SetMCPEnabled(v bool) {
|
|||||||
c.mcpEnabled.Store(v)
|
c.mcpEnabled.Store(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JWTIPChangeAllowed reports whether JWT sessions may change IP concurrently.
|
||||||
|
func (c *Config) JWTIPChangeAllowed() bool {
|
||||||
|
return c.jwtIPChangeAllowed.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetJWTIPChangeAllowed updates only the atomic mirror. save() synchronizes
|
||||||
|
// the public field for persistence; authentication must use the accessor.
|
||||||
|
func (c *Config) SetJWTIPChangeAllowed(v bool) {
|
||||||
|
c.jwtIPChangeAllowed.Store(v)
|
||||||
|
}
|
||||||
|
|
||||||
// Save 保存配置文件
|
// Save 保存配置文件
|
||||||
func (c *Config) Save() error {
|
func (c *Config) Save() error {
|
||||||
return c.save()
|
return c.save()
|
||||||
@@ -335,6 +366,7 @@ func (c *Config) patchYAMLField(key string, value any) error {
|
|||||||
|
|
||||||
func (c *Config) save() error {
|
func (c *Config) save() error {
|
||||||
c.EnableMCP = c.mcpEnabled.Load()
|
c.EnableMCP = c.mcpEnabled.Load()
|
||||||
|
c.AllowJWTIPChange = c.jwtIPChangeAllowed.Load()
|
||||||
data, err := yaml.Marshal(c)
|
data, err := yaml.Marshal(c)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -397,8 +429,9 @@ func koanfConf(c any) koanf.UnmarshalConf {
|
|||||||
WeaklyTypedInput: true,
|
WeaklyTypedInput: true,
|
||||||
MatchName: func(mapKey, fieldName string) bool {
|
MatchName: func(mapKey, fieldName string) bool {
|
||||||
return strings.EqualFold(mapKey, fieldName) ||
|
return strings.EqualFold(mapKey, fieldName) ||
|
||||||
strings.EqualFold(mapKey, strings.ReplaceAll(fieldName, "_", ""))
|
strings.EqualFold(strings.ReplaceAll(mapKey, "_", ""), strings.ReplaceAll(fieldName, "_", ""))
|
||||||
},
|
},
|
||||||
|
|
||||||
Squash: true,
|
Squash: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-1
@@ -24,6 +24,7 @@ func TestReadConfig(t *testing.T) {
|
|||||||
{"user_template", c.UserTemplate, c.UserTemplate == "user-dist"},
|
{"user_template", c.UserTemplate, c.UserTemplate == "user-dist"},
|
||||||
{"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"},
|
{"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"},
|
||||||
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""},
|
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""},
|
||||||
|
{"allow_jwt_ip_change", c.AllowJWTIPChange, !c.AllowJWTIPChange},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, field := range testFields {
|
for _, field := range testFields {
|
||||||
@@ -36,7 +37,24 @@ func TestReadConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ReadFile", func(t *testing.T) {
|
t.Run("ReadFile", func(t *testing.T) {
|
||||||
const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
|
const testCfg = `
|
||||||
|
allow_jwt_ip_change: true
|
||||||
|
jwt_secret_key: test
|
||||||
|
user_template: um
|
||||||
|
admin_template: am
|
||||||
|
agent_secret_key: none
|
||||||
|
site_name: lowkick
|
||||||
|
telegram_bot_token: 8155880635:AAH_test
|
||||||
|
telegram_admin_chat_id: "12345678"
|
||||||
|
web_real_ip_header: nz-realip
|
||||||
|
agent_real_ip_header: nz-realip
|
||||||
|
domain_expiry_notification_days: 100, 30, 7, 1, 0
|
||||||
|
server_expiry_notification_days: 30, 7, 3, 1, 0
|
||||||
|
smtp_server: us1.workspace.org:465
|
||||||
|
smtp_user: test@loohui.com
|
||||||
|
smtp_password: password123
|
||||||
|
admin_email: admin@loohui.com
|
||||||
|
`
|
||||||
|
|
||||||
var testFrontendTemplates = []FrontendTemplate{
|
var testFrontendTemplates = []FrontendTemplate{
|
||||||
{Path: "um"},
|
{Path: "um"},
|
||||||
@@ -59,6 +77,17 @@ func TestReadConfig(t *testing.T) {
|
|||||||
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
|
{"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
|
||||||
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
|
{"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
|
||||||
{"site_name", c.SiteName, c.SiteName == "lowkick"},
|
{"site_name", c.SiteName, c.SiteName == "lowkick"},
|
||||||
|
{"telegram_bot_token", c.TelegramBotToken, c.TelegramBotToken == "8155880635:AAH_test"},
|
||||||
|
{"telegram_admin_chat_id", c.TelegramAdminChatID, c.TelegramAdminChatID == "12345678"},
|
||||||
|
{"web_real_ip_header", c.WebRealIPHeader, c.WebRealIPHeader == "nz-realip"},
|
||||||
|
{"agent_real_ip_header", c.AgentRealIPHeader, c.AgentRealIPHeader == "nz-realip"},
|
||||||
|
{"domain_expiry_notification_days", c.DomainExpiryNotificationDays, c.DomainExpiryNotificationDays == "100, 30, 7, 1, 0"},
|
||||||
|
{"server_expiry_notification_days", c.ServerExpiryNotificationDays, c.ServerExpiryNotificationDays == "30, 7, 3, 1, 0"},
|
||||||
|
{"smtp_server", c.SMTPServer, c.SMTPServer == "us1.workspace.org:465"},
|
||||||
|
{"smtp_user", c.SMTPUser, c.SMTPUser == "test@loohui.com"},
|
||||||
|
{"smtp_password", c.SMTPPassword, c.SMTPPassword == "password123"},
|
||||||
|
{"admin_email", c.AdminEmail, c.AdminEmail == "admin@loohui.com"},
|
||||||
|
{"allow_jwt_ip_change", c.AllowJWTIPChange, c.AllowJWTIPChange},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, field := range testFields {
|
for _, field := range testFields {
|
||||||
|
|||||||
@@ -35,9 +35,28 @@ type HostState struct {
|
|||||||
ProcessCount uint64 `json:"process_count,omitempty"`
|
ProcessCount uint64 `json:"process_count,omitempty"`
|
||||||
Temperatures []SensorTemperature `json:"temperatures,omitempty"`
|
Temperatures []SensorTemperature `json:"temperatures,omitempty"`
|
||||||
GPU []float64 `json:"gpu,omitempty"`
|
GPU []float64 `json:"gpu,omitempty"`
|
||||||
|
GPUs []GPUStat `json:"gpus,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPUStat carries per-card figures, index-aligned with Host.GPU. Memory is in
|
||||||
|
// MiB and stays zero for vendors that do not report it, so MemoryTotal == 0
|
||||||
|
// means "unknown" rather than "no memory".
|
||||||
|
type GPUStat struct {
|
||||||
|
Utilization float64 `json:"utilization"`
|
||||||
|
MemoryUsed uint64 `json:"memory_used,omitempty"`
|
||||||
|
MemoryTotal uint64 `json:"memory_total,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *HostState) PB() *pb.State {
|
func (s *HostState) PB() *pb.State {
|
||||||
|
gs := make([]*pb.State_GPU, 0, len(s.GPUs))
|
||||||
|
for _, g := range s.GPUs {
|
||||||
|
gs = append(gs, &pb.State_GPU{
|
||||||
|
Utilization: g.Utilization,
|
||||||
|
MemoryUsed: g.MemoryUsed,
|
||||||
|
MemoryTotal: g.MemoryTotal,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var ts []*pb.State_SensorTemperature
|
var ts []*pb.State_SensorTemperature
|
||||||
for _, t := range s.Temperatures {
|
for _, t := range s.Temperatures {
|
||||||
ts = append(ts, &pb.State_SensorTemperature{
|
ts = append(ts, &pb.State_SensorTemperature{
|
||||||
@@ -64,10 +83,20 @@ func (s *HostState) PB() *pb.State {
|
|||||||
ProcessCount: s.ProcessCount,
|
ProcessCount: s.ProcessCount,
|
||||||
Temperatures: ts,
|
Temperatures: ts,
|
||||||
Gpu: s.GPU,
|
Gpu: s.GPU,
|
||||||
|
Gpus: gs,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PB2State(s *pb.State) HostState {
|
func PB2State(s *pb.State) HostState {
|
||||||
|
var gs []GPUStat
|
||||||
|
for _, g := range s.GetGpus() {
|
||||||
|
gs = append(gs, GPUStat{
|
||||||
|
Utilization: g.GetUtilization(),
|
||||||
|
MemoryUsed: g.GetMemoryUsed(),
|
||||||
|
MemoryTotal: g.GetMemoryTotal(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
var ts []SensorTemperature
|
var ts []SensorTemperature
|
||||||
for _, t := range s.GetTemperatures() {
|
for _, t := range s.GetTemperatures() {
|
||||||
ts = append(ts, SensorTemperature{
|
ts = append(ts, SensorTemperature{
|
||||||
@@ -93,6 +122,7 @@ func PB2State(s *pb.State) HostState {
|
|||||||
UdpConnCount: s.GetUdpConnCount(),
|
UdpConnCount: s.GetUdpConnCount(),
|
||||||
ProcessCount: s.GetProcessCount(),
|
ProcessCount: s.GetProcessCount(),
|
||||||
Temperatures: ts,
|
Temperatures: ts,
|
||||||
|
GPUs: gs,
|
||||||
GPU: s.GetGpu(),
|
GPU: s.GetGpu(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJWTIPChangeAllowedPersistence(t *testing.T) {
|
||||||
|
c := &Config{}
|
||||||
|
if c.JWTIPChangeAllowed() {
|
||||||
|
t.Fatal("zero-value Config must bind JWT sessions to their IP")
|
||||||
|
}
|
||||||
|
// An absent agent secret makes Read save the config during initialization.
|
||||||
|
path := newTempConfig(t, "jwt_secret_key: test\nallow_jwt_ip_change: true\n")
|
||||||
|
if err := c.Read(path, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !c.JWTIPChangeAllowed() {
|
||||||
|
t.Fatal("Read must initialize the atomic mirror before saving")
|
||||||
|
}
|
||||||
|
for _, want := range []bool{true, false} {
|
||||||
|
c.SetJWTIPChangeAllowed(want)
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reloaded := &Config{}
|
||||||
|
if err := reloaded.Read(path, nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if c.AllowJWTIPChange != want || reloaded.JWTIPChangeAllowed() != want {
|
||||||
|
t.Fatalf("saved and reloaded setting must be %v", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestJWTIPChangeAllowedConcurrentAccess(t *testing.T) {
|
||||||
|
c := &Config{filePath: filepath.Join(t.TempDir(), "config.yaml")}
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Go(func() {
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
c.SetJWTIPChangeAllowed(i%2 == 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
wg.Go(func() {
|
||||||
|
for i := 0; i < 10000; i++ {
|
||||||
|
_ = c.JWTIPChangeAllowed()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
if err := c.Save(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
+1
-30
@@ -143,7 +143,6 @@ func (ns *NotificationServerBundle) Send(message string) error {
|
|||||||
|
|
||||||
verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS
|
verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS
|
||||||
|
|
||||||
|
|
||||||
reqBody, err := ns.reqBody(message)
|
reqBody, err := ns.reqBody(message)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -209,6 +208,7 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ns.Server != nil {
|
if ns.Server != nil {
|
||||||
|
runtime := ns.Server.RuntimeSnapshot()
|
||||||
var noteData struct {
|
var noteData struct {
|
||||||
BillingDataMod struct {
|
BillingDataMod struct {
|
||||||
EndDate string `json:"endDate"`
|
EndDate string `json:"endDate"`
|
||||||
@@ -239,8 +239,6 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
|
|||||||
"#SERVER.BILLING_AMOUNT#", mod(amountStr),
|
"#SERVER.BILLING_AMOUNT#", mod(amountStr),
|
||||||
"#SERVER.BILLING_CYCLE#", mod(cycleStr),
|
"#SERVER.BILLING_CYCLE#", mod(cycleStr),
|
||||||
)
|
)
|
||||||
|
|
||||||
runtime := ns.Server.RuntimeSnapshot()
|
|
||||||
if runtime.State != nil && runtime.Host != nil {
|
if runtime.State != nil && runtime.Host != nil {
|
||||||
state := runtime.State
|
state := runtime.State
|
||||||
host := runtime.Host
|
host := runtime.Host
|
||||||
@@ -322,7 +320,6 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
|
|||||||
ipv6 = ip.IPv6Addr
|
ipv6 = ip.IPv6Addr
|
||||||
validIP = ipv6
|
validIP = ipv6
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
replacements = append(replacements,
|
replacements = append(replacements,
|
||||||
"#SERVER.IP#", mod(validIP),
|
"#SERVER.IP#", mod(validIP),
|
||||||
@@ -330,32 +327,6 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
|
|||||||
"#SERVER.IPV6#", mod(ipv6),
|
"#SERVER.IPV6#", mod(ipv6),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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...)
|
replacer := strings.NewReplacer(replacements...)
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 在线统计窗口的取值边界(天)。后台管理端可设置统计范围,查询窗口不允许
|
||||||
|
// 超过该设置,历史数据按该设置做保留清理。
|
||||||
|
const (
|
||||||
|
DefaultOnlineStatsDays = 7
|
||||||
|
MinOnlineStatsDays = 7
|
||||||
|
MaxOnlineStatsDays = 3650
|
||||||
|
)
|
||||||
|
|
||||||
|
// OnlineActiveWindow 服务器状态上报距该窗口内视为在线。与 MCP server.list
|
||||||
|
// 的 onlineWindow 口径保持一致;超过该窗口未上报的进行中会话由定时清扫关闭。
|
||||||
|
const OnlineActiveWindow = 30 * time.Second
|
||||||
|
|
||||||
|
// OnlineSession 记录一台服务器的一段连续在线时间。Ongoing=true 表示会话
|
||||||
|
// 仍在进行(此时 EndedAt 为零值);会话结束时写入 EndedAt 并置 Ongoing=false。
|
||||||
|
// LastActiveAt 由上报侧节流持久化,用于 dashboard 异常停机后把进行中的会话
|
||||||
|
// 结转到最后一次已知活跃时刻。
|
||||||
|
type OnlineSession struct {
|
||||||
|
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
|
||||||
|
CreatedAt time.Time `gorm:"index;<-:create" json:"created_at,omitempty"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
|
||||||
|
ServerID uint64 `gorm:"index:idx_online_sessions_server_started;index:idx_online_sessions_server_closed,priority:1;index:idx_online_sessions_server_open,priority:1" json:"server_id,omitempty"`
|
||||||
|
StartedAt time.Time `gorm:"index:idx_online_sessions_server_started;index:idx_online_sessions_server_open,priority:3" json:"started_at,omitempty"`
|
||||||
|
EndedAt time.Time `gorm:"index;index:idx_online_sessions_server_closed,priority:3" json:"ended_at,omitempty"` // 会话结束时刻,进行中为零值
|
||||||
|
// 上报侧节流持久化的最后活跃时刻(≥OnlineActiveWindow 间隔写一次)
|
||||||
|
LastActiveAt time.Time `json:"last_active_at,omitempty"`
|
||||||
|
Ongoing bool `gorm:"index:idx_online_sessions_server_closed,priority:2;index:idx_online_sessions_server_open,priority:2" json:"ongoing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BeforeSave keeps SQLite's lexicographic timestamp comparisons chronological.
|
||||||
|
// Query parameters and map-based updates must use UTC as well.
|
||||||
|
func (s *OnlineSession) BeforeSave(_ *gorm.DB) error {
|
||||||
|
s.StartedAt = s.StartedAt.UTC()
|
||||||
|
s.EndedAt = s.EndedAt.UTC()
|
||||||
|
s.LastActiveAt = s.LastActiveAt.UTC()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnlinePeriod 是裁剪到查询窗口后的在线时间段,供前端时间轴直接渲染。
|
||||||
|
type OnlinePeriod struct {
|
||||||
|
Start time.Time `json:"start"`
|
||||||
|
End time.Time `json:"end"`
|
||||||
|
Ongoing bool `json:"ongoing"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyOnlineStat 是单个自然日(面板时区)在查询窗口内的在线秒数。
|
||||||
|
// 窗口首尾为非整日时,只统计与窗口重叠的部分。
|
||||||
|
type DailyOnlineStat struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
OnlineSeconds uint64 `json:"online_seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnlineHistoryResponse 是单台服务器在线时间段统计的聚合结果。
|
||||||
|
type OnlineHistoryResponse struct {
|
||||||
|
ServerID uint64 `json:"server_id"`
|
||||||
|
ServerName string `json:"server_name"`
|
||||||
|
Days int `json:"days"`
|
||||||
|
MaxDays int `json:"max_days"`
|
||||||
|
// Daily 拆日所用面板时区:IANA 名与窗口末端相对 UTC 的偏移秒数,
|
||||||
|
// 前端按同一时区渲染,保证跨时区访客的图形与数字一致
|
||||||
|
Timezone string `json:"timezone"`
|
||||||
|
TzOffset int `json:"tz_offset"`
|
||||||
|
WindowStart time.Time `json:"window_start"`
|
||||||
|
WindowEnd time.Time `json:"window_end"`
|
||||||
|
TotalOnlineSeconds uint64 `json:"total_online_seconds"`
|
||||||
|
OnlineRate float64 `json:"online_rate"`
|
||||||
|
CurrentlyOnline bool `json:"currently_online"`
|
||||||
|
// 从未上报过的服务器该值为 nil;指针避免把 Go 零值时间序列化成 0001-01-01
|
||||||
|
LastActive *time.Time `json:"last_active,omitempty"`
|
||||||
|
Periods []OnlinePeriod `json:"periods"`
|
||||||
|
Daily []DailyOnlineStat `json:"daily"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClipOnlineSessions 把与窗口 [windowStart, windowEnd) 相交的会话裁剪成
|
||||||
|
// OnlinePeriod 列表。进行中的会话以 windowEnd 为右边界(保留 Ongoing 标记)。
|
||||||
|
// 输入需按 StartedAt 升序;输出与输入同序。
|
||||||
|
func ClipOnlineSessions(sessions []OnlineSession, windowStart, windowEnd time.Time) []OnlinePeriod {
|
||||||
|
periods := make([]OnlinePeriod, 0, len(sessions))
|
||||||
|
for _, s := range sessions {
|
||||||
|
start := s.StartedAt
|
||||||
|
if start.Before(windowStart) {
|
||||||
|
start = windowStart
|
||||||
|
}
|
||||||
|
end := s.EndedAt
|
||||||
|
ongoing := s.Ongoing || end.IsZero()
|
||||||
|
if ongoing || end.After(windowEnd) {
|
||||||
|
end = windowEnd
|
||||||
|
}
|
||||||
|
if !start.Before(end) {
|
||||||
|
// 与窗口零交集(理论上不会由查询条件选出)或空会话
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
periods = append(periods, OnlinePeriod{Start: start, End: end, Ongoing: ongoing})
|
||||||
|
}
|
||||||
|
return periods
|
||||||
|
}
|
||||||
|
|
||||||
|
// DailyOnlineBreakdown 把在线时间段按面板时区的自然日拆分,返回窗口覆盖的
|
||||||
|
// 每一天的在线秒数(含在线为 0 秒的日期),按日期升序。
|
||||||
|
// 取整口径:每天先累计原始时长(日界切分不损失精度),仅在每日汇总时向下
|
||||||
|
// 取整一次;调用方以各日之和作为总时长,保证响应内部 total 与 daily[] 严格
|
||||||
|
// 对账,不与"逐段截断再累加"的口径混用(亚秒会话会让后者系统性偏小)。
|
||||||
|
// 时段二分定位起始日界后逐日推进,复杂度 O(P·log D + D);单服务器的会话
|
||||||
|
// 互不重叠,内层推进总量受窗口天数约束,长窗口(如 days=3650)下不会出现
|
||||||
|
// 时段数×天数的双重循环。
|
||||||
|
func DailyOnlineBreakdown(periods []OnlinePeriod, windowStart, windowEnd time.Time, loc *time.Location) []DailyOnlineStat {
|
||||||
|
first := windowStart.In(loc)
|
||||||
|
// Advance a timezone-independent calendar, not a normalized local midnight:
|
||||||
|
// midnight DST gaps can normalize into the previous day and repeat dates.
|
||||||
|
date := time.Date(first.Year(), first.Month(), first.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
var days []DailyOnlineStat
|
||||||
|
var dayStarts, dayEnds []time.Time
|
||||||
|
for {
|
||||||
|
start := onlineCalendarBoundary(date, loc)
|
||||||
|
if !start.Before(windowEnd) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
nextDate := date.AddDate(0, 0, 1)
|
||||||
|
end := onlineCalendarBoundary(nextDate, loc)
|
||||||
|
if start.Before(end) {
|
||||||
|
dayStarts = append(dayStarts, start)
|
||||||
|
dayEnds = append(dayEnds, end)
|
||||||
|
days = append(days, DailyOnlineStat{Date: date.Format("2006-01-02")})
|
||||||
|
}
|
||||||
|
date = nextDate
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(days) == 0 {
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
dayDurations := make([]time.Duration, len(days))
|
||||||
|
for _, p := range periods {
|
||||||
|
// 该时段覆盖的第一天:不晚于时段开始的最后一个日界
|
||||||
|
i := sort.Search(len(dayStarts), func(i int) bool {
|
||||||
|
return dayStarts[i].After(p.Start)
|
||||||
|
}) - 1
|
||||||
|
if i < 0 {
|
||||||
|
i = 0
|
||||||
|
}
|
||||||
|
// 逐日推进,累加该时段与每一天 [dayStart, nextDay) 的交集
|
||||||
|
for ; i < len(dayStarts) && dayStarts[i].Before(p.End); i++ {
|
||||||
|
overlapStart := maxTime(p.Start, dayStarts[i])
|
||||||
|
overlapEnd := minTime(p.End, dayEnds[i])
|
||||||
|
if overlapStart.Before(overlapEnd) {
|
||||||
|
dayDurations[i] += overlapEnd.Sub(overlapStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range days {
|
||||||
|
days[i].OnlineSeconds = uint64(dayDurations[i].Seconds())
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxTime(a, b time.Time) time.Time {
|
||||||
|
if a.After(b) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func minTime(a, b time.Time) time.Time {
|
||||||
|
if a.Before(b) {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// A missing midnight (or an entirely skipped date) starts at the timezone
|
||||||
|
// transition, not at the previous date returned by time.Date normalization.
|
||||||
|
func onlineCalendarBoundary(date time.Time, loc *time.Location) time.Time {
|
||||||
|
boundary := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, loc)
|
||||||
|
if boundary.Format("2006-01-02") < date.Format("2006-01-02") {
|
||||||
|
_, end := boundary.ZoneBounds()
|
||||||
|
if !end.IsZero() {
|
||||||
|
return end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// time.Date may choose the second midnight in a rollback. Recover the
|
||||||
|
// earlier occurrence using the offset on the preceding side of the transition.
|
||||||
|
zoneStart, _ := boundary.ZoneBounds()
|
||||||
|
if !zoneStart.IsZero() {
|
||||||
|
_, currentOffset := boundary.Zone()
|
||||||
|
_, previousOffset := zoneStart.Add(-time.Nanosecond).Zone()
|
||||||
|
if previousOffset > currentOffset {
|
||||||
|
earlier := boundary.Add(-time.Duration(previousOffset-currentOffset) * time.Second)
|
||||||
|
if earlier.Format("2006-01-02 15:04:05") == boundary.Format("2006-01-02 15:04:05") {
|
||||||
|
boundary = earlier
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return boundary
|
||||||
|
}
|
||||||
|
|
||||||
|
// OnlineHistoryWindowStart defines the shared query and retention calendar window.
|
||||||
|
func OnlineHistoryWindowStart(now time.Time, days int, loc *time.Location) time.Time {
|
||||||
|
// Calendar days follow the dashboard timezone, not the host timezone;
|
||||||
|
// DST days can contain 23 or 25 hours. Queries and pruning must agree.
|
||||||
|
if loc == nil {
|
||||||
|
loc = time.UTC
|
||||||
|
}
|
||||||
|
return now.In(loc).AddDate(0, 0, -days).UTC()
|
||||||
|
}
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math/rand"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
var testZone = time.FixedZone("TEST", 8*3600)
|
||||||
|
|
||||||
|
func ts(y int, mo time.Month, d, h, mi, s int) time.Time {
|
||||||
|
return time.Date(y, mo, d, h, mi, s, 0, testZone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClipOnlineSessions(t *testing.T) {
|
||||||
|
windowStart := ts(2026, 10, 1, 0, 0, 0)
|
||||||
|
windowEnd := ts(2026, 10, 8, 0, 0, 0)
|
||||||
|
|
||||||
|
t.Run("session fully inside window", func(t *testing.T) {
|
||||||
|
sessions := []OnlineSession{
|
||||||
|
{ServerID: 1, StartedAt: ts(2026, 10, 2, 8, 0, 0), EndedAt: ts(2026, 10, 2, 9, 30, 0)},
|
||||||
|
}
|
||||||
|
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||||
|
require.Len(t, periods, 1)
|
||||||
|
require.Equal(t, ts(2026, 10, 2, 8, 0, 0), periods[0].Start)
|
||||||
|
require.Equal(t, ts(2026, 10, 2, 9, 30, 0), periods[0].End)
|
||||||
|
require.False(t, periods[0].Ongoing)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("session started before window is clipped", func(t *testing.T) {
|
||||||
|
sessions := []OnlineSession{
|
||||||
|
{ServerID: 1, StartedAt: ts(2026, 9, 28, 12, 0, 0), EndedAt: ts(2026, 10, 1, 5, 0, 0)},
|
||||||
|
}
|
||||||
|
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||||
|
require.Len(t, periods, 1)
|
||||||
|
require.Equal(t, windowStart, periods[0].Start)
|
||||||
|
require.Equal(t, ts(2026, 10, 1, 5, 0, 0), periods[0].End)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ongoing session ends at window end", func(t *testing.T) {
|
||||||
|
sessions := []OnlineSession{
|
||||||
|
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0), Ongoing: true},
|
||||||
|
}
|
||||||
|
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||||
|
require.Len(t, periods, 1)
|
||||||
|
require.Equal(t, windowEnd, periods[0].End)
|
||||||
|
require.True(t, periods[0].Ongoing)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero ended_at treated as ongoing", func(t *testing.T) {
|
||||||
|
sessions := []OnlineSession{
|
||||||
|
{ServerID: 1, StartedAt: ts(2026, 10, 7, 10, 0, 0)},
|
||||||
|
}
|
||||||
|
periods := ClipOnlineSessions(sessions, windowStart, windowEnd)
|
||||||
|
require.Len(t, periods, 1)
|
||||||
|
require.True(t, periods[0].Ongoing)
|
||||||
|
require.Equal(t, windowEnd, periods[0].End)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("session outside window yields no period", func(t *testing.T) {
|
||||||
|
sessions := []OnlineSession{
|
||||||
|
{ServerID: 1, StartedAt: ts(2026, 9, 1, 0, 0, 0), EndedAt: ts(2026, 9, 2, 0, 0, 0)},
|
||||||
|
}
|
||||||
|
require.Empty(t, ClipOnlineSessions(sessions, windowStart, windowEnd))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyOnlineBreakdown(t *testing.T) {
|
||||||
|
loc := time.FixedZone("UTC+8", 8*3600)
|
||||||
|
windowStart := time.Date(2026, 10, 1, 10, 0, 0, 0, loc)
|
||||||
|
windowEnd := time.Date(2026, 10, 3, 14, 0, 0, 0, loc)
|
||||||
|
|
||||||
|
periods := []OnlinePeriod{
|
||||||
|
// 跨午夜:2 日 23:00 ~ 3 日 01:00
|
||||||
|
{Start: time.Date(2026, 10, 2, 23, 0, 0, 0, loc), End: time.Date(2026, 10, 3, 1, 0, 0, 0, loc)},
|
||||||
|
// 窗口前就开始的会话,裁剪后从窗口起点开始
|
||||||
|
{Start: windowStart, End: time.Date(2026, 10, 1, 12, 0, 0, 0, loc)},
|
||||||
|
}
|
||||||
|
|
||||||
|
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||||
|
require.Len(t, daily, 3)
|
||||||
|
require.Equal(t, "2026-10-01", daily[0].Date)
|
||||||
|
require.Equal(t, "2026-10-02", daily[1].Date)
|
||||||
|
require.Equal(t, "2026-10-03", daily[2].Date)
|
||||||
|
|
||||||
|
// 1 日:12:00-10:00 = 2h;2 日:23:00-24:00 = 1h;3 日:0:00-1:00 + 窗口外无 = 1h
|
||||||
|
require.Equal(t, uint64(7200), daily[0].OnlineSeconds)
|
||||||
|
require.Equal(t, uint64(3600), daily[1].OnlineSeconds)
|
||||||
|
require.Equal(t, uint64(3600), daily[2].OnlineSeconds)
|
||||||
|
|
||||||
|
// 每日合计即总时长(口径:每日累计原始时长后统一取整)
|
||||||
|
var sum uint64
|
||||||
|
for _, d := range daily {
|
||||||
|
sum += d.OnlineSeconds
|
||||||
|
}
|
||||||
|
require.Equal(t, uint64(7200+3600+3600), sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 评审回归:取整必须按日累计原始时长后统一转换。逐段截断再累加会让亚秒
|
||||||
|
// 会话系统性偏小——同一天两个 1.5s 的会话应得 3s,而不是 1+1=2s
|
||||||
|
func TestDailyOnlineBreakdownSubSecond(t *testing.T) {
|
||||||
|
loc := testZone
|
||||||
|
windowStart := ts(2026, 10, 1, 0, 0, 0)
|
||||||
|
windowEnd := ts(2026, 10, 2, 0, 0, 0)
|
||||||
|
periods := []OnlinePeriod{
|
||||||
|
{Start: windowStart.Add(100 * time.Millisecond), End: windowStart.Add(1600 * time.Millisecond)},
|
||||||
|
{Start: windowStart.Add(2 * time.Second), End: windowStart.Add(3500 * time.Millisecond)},
|
||||||
|
}
|
||||||
|
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||||
|
require.Len(t, daily, 1)
|
||||||
|
require.Equal(t, uint64(3), daily[0].OnlineSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跨日时段按日界切分不损失精度:各日分别累计原始时长后各自取整
|
||||||
|
func TestDailyOnlineBreakdownCrossDayFraction(t *testing.T) {
|
||||||
|
loc := testZone
|
||||||
|
windowStart := ts(2026, 10, 1, 0, 0, 0)
|
||||||
|
windowEnd := ts(2026, 10, 3, 0, 0, 0)
|
||||||
|
periods := []OnlinePeriod{{
|
||||||
|
Start: time.Date(2026, 10, 1, 23, 59, 59, 250000000, loc),
|
||||||
|
End: time.Date(2026, 10, 2, 0, 0, 2, 750000000, loc),
|
||||||
|
}}
|
||||||
|
daily := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||||
|
require.Len(t, daily, 2)
|
||||||
|
require.Equal(t, uint64(0), daily[0].OnlineSeconds) // 0.75s → 0
|
||||||
|
require.Equal(t, uint64(2), daily[1].OnlineSeconds) // 2.75s → 2
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyOnlineBreakdownZeroOnline(t *testing.T) {
|
||||||
|
loc := time.FixedZone("UTC+8", 8*3600)
|
||||||
|
windowStart := time.Date(2026, 10, 1, 0, 0, 0, 0, loc)
|
||||||
|
windowEnd := time.Date(2026, 10, 4, 0, 0, 0, 0, loc)
|
||||||
|
daily := DailyOnlineBreakdown(nil, windowStart, windowEnd, loc)
|
||||||
|
require.Len(t, daily, 3)
|
||||||
|
for _, d := range daily {
|
||||||
|
require.Zero(t, d.OnlineSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineStatsDaysBounds(t *testing.T) {
|
||||||
|
require.Equal(t, 7, DefaultOnlineStatsDays)
|
||||||
|
require.Equal(t, 7, MinOnlineStatsDays)
|
||||||
|
require.Equal(t, 3650, MaxOnlineStatsDays)
|
||||||
|
}
|
||||||
|
|
||||||
|
// F1 回归保护:指针推进实现必须与逐日朴素实现对任意不相交时段集合等价
|
||||||
|
// (单服务器会话互不重叠的约束一致),长窗口大量时段下不再随时段数平方放大。
|
||||||
|
func TestDailyOnlineBreakdownEquivalence(t *testing.T) {
|
||||||
|
loc := testZone
|
||||||
|
windowStart := ts(2026, 1, 1, 0, 0, 0)
|
||||||
|
windowEnd := windowStart.AddDate(0, 0, 90)
|
||||||
|
|
||||||
|
rng := rand.New(rand.NewSource(42))
|
||||||
|
var periods []OnlinePeriod
|
||||||
|
cursor := windowStart
|
||||||
|
for cursor.Before(windowEnd) {
|
||||||
|
// 起止带亚秒抖动:让逐段截断与按日累计两种口径可区分
|
||||||
|
start := cursor.Add(time.Duration(rng.Intn(4*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
|
||||||
|
end := start.Add(time.Duration(1+rng.Intn(6*3600))*time.Second + time.Duration(rng.Int63n(int64(time.Second))))
|
||||||
|
if end.After(windowEnd) {
|
||||||
|
end = windowEnd
|
||||||
|
}
|
||||||
|
periods = append(periods, OnlinePeriod{Start: start, End: end})
|
||||||
|
cursor = end.Add(time.Duration(1+rng.Intn(3600)) * time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
days := DailyOnlineBreakdown(periods, windowStart, windowEnd, loc)
|
||||||
|
|
||||||
|
// 参考实现与生产同一口径:每日累计原始时长、汇总时向下取整一次
|
||||||
|
reference := map[string]time.Duration{}
|
||||||
|
for _, p := range periods {
|
||||||
|
for d := windowStart; d.Before(windowEnd); d = d.AddDate(0, 0, 1) {
|
||||||
|
overlapStart := maxTime(p.Start, d)
|
||||||
|
overlapEnd := minTime(p.End, d.AddDate(0, 0, 1))
|
||||||
|
if overlapStart.Before(overlapEnd) {
|
||||||
|
reference[d.Format("2006-01-02")] += overlapEnd.Sub(overlapStart)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Len(t, days, 90)
|
||||||
|
for _, day := range days {
|
||||||
|
require.Equal(t, uint64(reference[day.Date].Seconds()), day.OnlineSeconds, "date %s", day.Date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyOnlineBreakdownMidnightDSTTransition(t *testing.T) {
|
||||||
|
loc, err := time.LoadLocation("America/Sao_Paulo")
|
||||||
|
require.NoError(t, err)
|
||||||
|
start := time.Date(2018, 11, 3, 0, 0, 0, 0, loc)
|
||||||
|
end := time.Date(2018, 11, 6, 0, 0, 0, 0, loc)
|
||||||
|
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
|
||||||
|
require.Equal(t, []DailyOnlineStat{{Date: "2018-11-03", OnlineSeconds: 86400}, {Date: "2018-11-04", OnlineSeconds: 82800}, {Date: "2018-11-05", OnlineSeconds: 86400}}, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDailyOnlineBreakdownRepeatedMidnight(t *testing.T) {
|
||||||
|
loc, err := time.LoadLocation("Asia/Amman")
|
||||||
|
require.NoError(t, err)
|
||||||
|
start := time.Date(2021, 10, 28, 21, 0, 0, 0, time.UTC)
|
||||||
|
end := time.Date(2021, 10, 29, 22, 0, 0, 0, time.UTC)
|
||||||
|
days := DailyOnlineBreakdown([]OnlinePeriod{{Start: start, End: end}}, start, end, loc)
|
||||||
|
require.Equal(t, []DailyOnlineStat{{Date: "2021-10-29", OnlineSeconds: 90000}}, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryWindowUsesDashboardCalendar(t *testing.T) {
|
||||||
|
loc, err := time.LoadLocation("America/New_York")
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, test := range []struct {
|
||||||
|
month time.Month
|
||||||
|
day, hours int
|
||||||
|
}{{time.March, 9, 23}, {time.November, 2, 25}} {
|
||||||
|
end := time.Date(2026, test.month, test.day, 0, 30, 0, 0, loc)
|
||||||
|
start := OnlineHistoryWindowStart(end.UTC(), 1, loc)
|
||||||
|
require.True(t, start.Equal(time.Date(2026, test.month, test.day-1, 0, 30, 0, 0, loc)))
|
||||||
|
require.Equal(t, time.Duration(test.hours)*time.Hour, end.Sub(start))
|
||||||
|
require.Len(t, DailyOnlineBreakdown(nil, start, end, loc), 2)
|
||||||
|
require.True(t, OnlineHistoryWindowStart(end.UTC(), 7, loc).Equal(end.In(loc).AddDate(0, 0, -7)))
|
||||||
|
}
|
||||||
|
}
|
||||||
+219
-110
@@ -1,6 +1,8 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
|
"math"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,12 +17,23 @@ const (
|
|||||||
RuleCoverIgnoreAll
|
RuleCoverIgnoreAll
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// MaxAlertRuleDuration is the largest duration that can be converted to int
|
||||||
|
// on every architecture supported by Go. Alert rules are persisted as uint64,
|
||||||
|
// but their sampling windows use int indexes; keeping the public bound at
|
||||||
|
// MaxInt32 prevents a crafted value from wrapping during that conversion.
|
||||||
|
const MaxAlertRuleDuration uint64 = 1<<31 - 1
|
||||||
|
|
||||||
|
// MaxAlertRuleCycleInterval also leaves room for the largest integer
|
||||||
|
// multiplication performed by the calendar helpers (weeks become 7*interval)
|
||||||
|
// on 32-bit targets.
|
||||||
|
const MaxAlertRuleCycleInterval uint64 = (1<<31 - 1) / 7
|
||||||
|
|
||||||
type NResult struct {
|
type NResult struct {
|
||||||
N uint64
|
N uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Rule struct {
|
type Rule struct {
|
||||||
// 指标类型,cpu、memory、swap、disk、net_in_speed、net_out_speed
|
// 指标类型,cpu、gpu/gpu_max、memory、swap、disk、net_in_speed、net_out_speed
|
||||||
// net_all_speed、transfer_in、transfer_out、transfer_all、offline
|
// net_all_speed、transfer_in、transfer_out、transfer_all、offline
|
||||||
// transfer_in_cycle、transfer_out_cycle、transfer_all_cycle
|
// transfer_in_cycle、transfer_out_cycle、transfer_all_cycle
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@@ -45,47 +58,147 @@ func percentage(used, total uint64) float64 {
|
|||||||
return float64(used) * 100 / float64(total)
|
return float64(used) * 100 / float64(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot 未通过规则返回 false, 通过返回 true
|
// IsSupportedType reports whether the rule type has an evaluator. Keep this
|
||||||
|
// list in lockstep with Snapshot's switch so untrusted strings cannot enter the
|
||||||
|
// persisted alert pipeline and silently acquire fallback semantics.
|
||||||
|
func (u *Rule) IsSupportedType() bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch u.Type {
|
||||||
|
case "cpu", "gpu", "gpu_max", "memory", "swap", "disk",
|
||||||
|
"net_in_speed", "net_out_speed", "net_all_speed",
|
||||||
|
"transfer_in", "transfer_out", "transfer_all", "offline",
|
||||||
|
"transfer_in_cycle", "transfer_out_cycle", "transfer_all_cycle",
|
||||||
|
"load1", "load5", "load15", "tcp_conn_count", "udp_conn_count",
|
||||||
|
"process_count", "temperature_max":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DurationInt converts the persisted duration without allowing uint64-to-int
|
||||||
|
// truncation. Callers must handle ok=false as an invalid rule.
|
||||||
|
func (u *Rule) DurationInt() (duration int, ok bool) {
|
||||||
|
if u == nil || u.Duration > MaxAlertRuleDuration {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return int(u.Duration), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasSafeCycleConfiguration guards every value that the cycle evaluator later
|
||||||
|
// converts or dereferences. It intentionally does not reject an empty unit,
|
||||||
|
// which is the documented legacy spelling for hours.
|
||||||
|
func (u *Rule) HasSafeCycleConfiguration() bool {
|
||||||
|
return u != nil && u.IsTransferDurationRule() && u.CycleStart != nil &&
|
||||||
|
!u.CycleStart.After(time.Now()) && u.CycleInterval > 0 && u.CycleInterval <= MaxAlertRuleCycleInterval &&
|
||||||
|
u.HasSafeThresholds() && u.HasSafeCycleUnit()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Rule) HasSafeCycleUnit() bool {
|
||||||
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch strings.ToLower(u.CycleUnit) {
|
||||||
|
case "", "hour", "day", "week", "month", "year":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Rule) HasSafeThresholds() bool {
|
||||||
|
if u == nil || math.IsNaN(u.Min) || math.IsNaN(u.Max) || math.IsInf(u.Min, 0) || math.IsInf(u.Max, 0) || u.Min < 0 || u.Max < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return u.Min == 0 || u.Max == 0 || u.Min <= u.Max
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot is retained for callers which only need a boolean result. The alert
|
||||||
|
// sentinel uses Evaluate so missing telemetry is not mistaken for a breach.
|
||||||
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) bool {
|
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) bool {
|
||||||
|
passed, _ := u.Evaluate(cycleTransferStats, server, db)
|
||||||
|
return passed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate returns known=false when a metric cannot be evaluated from the
|
||||||
|
// current report. Unknown must neither trigger nor resolve an incident.
|
||||||
|
func (u *Rule) Evaluate(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) (passed, known bool) {
|
||||||
|
if server == nil {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
return u.EvaluateRuntime(cycleTransferStats, server, server.RuntimeSnapshot(), db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EvaluateRuntime evaluates all conditions against the same immutable Agent
|
||||||
|
// report. Callers evaluating a compound rule must share this snapshot.
|
||||||
|
func (u *Rule) EvaluateRuntime(cycleTransferStats *CycleTransferStats, server *Server, runtime RuntimeSnapshot, db *gorm.DB) (passed, known bool) {
|
||||||
|
if u == nil || server == nil || !u.IsSupportedType() {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
if u.IsTransferDurationRule() && (!u.HasSafeCycleConfiguration() || cycleTransferStats == nil) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
// 监控全部但是排除了此服务器
|
// 监控全部但是排除了此服务器
|
||||||
if u.Cover == RuleCoverAll && u.Ignore[server.ID] {
|
if u.Cover == RuleCoverAll && u.Ignore[server.ID] {
|
||||||
return true
|
return true, true
|
||||||
}
|
}
|
||||||
// 忽略全部但是指定监控了此服务器
|
// 忽略全部但是指定监控了此服务器
|
||||||
if u.Cover == RuleCoverIgnoreAll && !u.Ignore[server.ID] {
|
if u.Cover == RuleCoverIgnoreAll && !u.Ignore[server.ID] {
|
||||||
return true
|
return true, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
evalNow := time.Now()
|
||||||
|
var cycleStart, cycleEnd time.Time
|
||||||
|
if u.IsTransferDurationRule() {
|
||||||
|
cycleStart, cycleEnd = u.transferDurationBounds(evalNow)
|
||||||
|
}
|
||||||
// 循环区间流量检测 · 短期无需重复检测
|
// 循环区间流量检测 · 短期无需重复检测
|
||||||
if u.IsTransferDurationRule() && u.NextTransferAt[server.ID].After(time.Now()) {
|
if u.IsTransferDurationRule() && u.NextTransferAt[server.ID].After(evalNow) {
|
||||||
return u.LastCycleStatus[server.ID]
|
return u.LastCycleStatus[server.ID], true
|
||||||
}
|
}
|
||||||
|
|
||||||
var src float64
|
var src float64
|
||||||
runtime := server.RuntimeSnapshot()
|
if u.IsOfflineRule() {
|
||||||
|
return !runtime.LastActive.IsZero() && time.Since(runtime.LastActive) <= 6*time.Second, true
|
||||||
|
}
|
||||||
if runtime.State == nil {
|
if runtime.State == nil {
|
||||||
return false
|
return true, false
|
||||||
}
|
}
|
||||||
state := runtime.State
|
state := runtime.State
|
||||||
|
|
||||||
switch u.Type {
|
switch u.Type {
|
||||||
case "cpu":
|
case "cpu":
|
||||||
src = float64(state.CPU)
|
src = float64(state.CPU)
|
||||||
case "gpu_max":
|
case "gpu", "gpu_max":
|
||||||
|
if len(state.GPU) == 0 {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
src = slices.Max(state.GPU)
|
src = slices.Max(state.GPU)
|
||||||
case "memory":
|
case "memory":
|
||||||
if runtime.Host == nil {
|
if runtime.Host == nil {
|
||||||
return false
|
return true, false
|
||||||
|
}
|
||||||
|
if runtime.Host.MemTotal == 0 {
|
||||||
|
return true, false
|
||||||
}
|
}
|
||||||
src = percentage(state.MemUsed, runtime.Host.MemTotal)
|
src = percentage(state.MemUsed, runtime.Host.MemTotal)
|
||||||
case "swap":
|
case "swap":
|
||||||
if runtime.Host == nil {
|
if runtime.Host == nil {
|
||||||
return false
|
return true, false
|
||||||
|
}
|
||||||
|
if runtime.Host.SwapTotal == 0 {
|
||||||
|
return true, false
|
||||||
}
|
}
|
||||||
src = percentage(state.SwapUsed, runtime.Host.SwapTotal)
|
src = percentage(state.SwapUsed, runtime.Host.SwapTotal)
|
||||||
case "disk":
|
case "disk":
|
||||||
if runtime.Host == nil {
|
if runtime.Host == nil {
|
||||||
return false
|
return true, false
|
||||||
|
}
|
||||||
|
if runtime.Host.DiskTotal == 0 {
|
||||||
|
return true, false
|
||||||
}
|
}
|
||||||
src = percentage(state.DiskUsed, runtime.Host.DiskTotal)
|
src = percentage(state.DiskUsed, runtime.Host.DiskTotal)
|
||||||
case "net_in_speed":
|
case "net_in_speed":
|
||||||
@@ -93,38 +206,50 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
|
|||||||
case "net_out_speed":
|
case "net_out_speed":
|
||||||
src = float64(state.NetOutSpeed)
|
src = float64(state.NetOutSpeed)
|
||||||
case "net_all_speed":
|
case "net_all_speed":
|
||||||
src = float64(state.NetOutSpeed + state.NetOutSpeed)
|
src = float64(state.NetInSpeed) + float64(state.NetOutSpeed)
|
||||||
case "transfer_in":
|
case "transfer_in":
|
||||||
src = float64(state.NetInTransfer)
|
src = float64(state.NetInTransfer)
|
||||||
case "transfer_out":
|
case "transfer_out":
|
||||||
src = float64(state.NetOutTransfer)
|
src = float64(state.NetOutTransfer)
|
||||||
case "transfer_all":
|
case "transfer_all":
|
||||||
src = float64(state.NetOutTransfer + state.NetInTransfer)
|
src = float64(state.NetOutTransfer + state.NetInTransfer)
|
||||||
case "offline":
|
|
||||||
if runtime.LastActive.IsZero() {
|
|
||||||
src = 0
|
|
||||||
} else {
|
|
||||||
src = float64(runtime.LastActive.Unix())
|
|
||||||
}
|
|
||||||
case "transfer_in_cycle":
|
case "transfer_in_cycle":
|
||||||
|
if db == nil {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
src = float64(utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
|
src = float64(utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
|
||||||
if u.CycleInterval != 0 {
|
if u.CycleInterval != 0 {
|
||||||
var res NResult
|
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)
|
if err := db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND datetime(`created_at`) < datetime(?) AND datetime(`created_at`) <= datetime(?) AND server_id = ?", cycleStart.UTC(), cycleEnd.UTC(), evalNow.UTC(), server.ID).Scan(&res).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Alert cycle transfer query failed for rule %s server %d: %v", u.Type, server.ID, err)
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
src += float64(res.N)
|
src += float64(res.N)
|
||||||
}
|
}
|
||||||
case "transfer_out_cycle":
|
case "transfer_out_cycle":
|
||||||
|
if db == nil {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot))
|
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot))
|
||||||
if u.CycleInterval != 0 {
|
if u.CycleInterval != 0 {
|
||||||
var res NResult
|
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)
|
if err := db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND datetime(`created_at`) < datetime(?) AND datetime(`created_at`) <= datetime(?) AND server_id = ?", cycleStart.UTC(), cycleEnd.UTC(), evalNow.UTC(), server.ID).Scan(&res).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Alert cycle transfer query failed for rule %s server %d: %v", u.Type, server.ID, err)
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
src += float64(res.N)
|
src += float64(res.N)
|
||||||
}
|
}
|
||||||
case "transfer_all_cycle":
|
case "transfer_all_cycle":
|
||||||
|
if db == nil {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot) + utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
|
src = float64(utils.SubUintChecked(state.NetOutTransfer, runtime.PrevTransferOutSnapshot) + utils.SubUintChecked(state.NetInTransfer, runtime.PrevTransferInSnapshot))
|
||||||
if u.CycleInterval != 0 {
|
if u.CycleInterval != 0 {
|
||||||
var res NResult
|
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)
|
if err := db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND datetime(`created_at`) < datetime(?) AND datetime(`created_at`) <= datetime(?) AND server_id = ?", cycleStart.UTC(), cycleEnd.UTC(), evalNow.UTC(), server.ID).Scan(&res).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Alert cycle transfer query failed for rule %s server %d: %v", u.Type, server.ID, err)
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
src += float64(res.N)
|
src += float64(res.N)
|
||||||
}
|
}
|
||||||
case "load1":
|
case "load1":
|
||||||
@@ -141,26 +266,38 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
|
|||||||
src = float64(state.ProcessCount)
|
src = float64(state.ProcessCount)
|
||||||
case "temperature_max":
|
case "temperature_max":
|
||||||
var temp []float64
|
var temp []float64
|
||||||
if state.Temperatures != nil {
|
|
||||||
for _, tempStat := range state.Temperatures {
|
for _, tempStat := range state.Temperatures {
|
||||||
if tempStat.Temperature != 0 {
|
if tempStat.Temperature != 0 {
|
||||||
temp = append(temp, tempStat.Temperature)
|
temp = append(temp, tempStat.Temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
src = slices.Max(temp)
|
if len(temp) == 0 {
|
||||||
|
return true, false
|
||||||
}
|
}
|
||||||
|
src = slices.Max(temp)
|
||||||
|
default:
|
||||||
|
return true, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// 循环区间流量检测 · 更新下次需要检测时间
|
// 循环区间流量检测 · 更新下次需要检测时间
|
||||||
if u.IsTransferDurationRule() {
|
if u.IsTransferDurationRule() {
|
||||||
seconds := max(1800*((u.Max-src)/u.Max), 180)
|
seconds := float64(180)
|
||||||
|
if u.Max > 0 {
|
||||||
|
seconds = max(1800*((u.Max-src)/u.Max), 180)
|
||||||
|
} else if u.Min > 0 {
|
||||||
|
seconds = 1800
|
||||||
|
}
|
||||||
if u.NextTransferAt == nil {
|
if u.NextTransferAt == nil {
|
||||||
u.NextTransferAt = make(map[uint64]time.Time)
|
u.NextTransferAt = make(map[uint64]time.Time)
|
||||||
}
|
}
|
||||||
if u.LastCycleStatus == nil {
|
if u.LastCycleStatus == nil {
|
||||||
u.LastCycleStatus = make(map[uint64]bool)
|
u.LastCycleStatus = make(map[uint64]bool)
|
||||||
}
|
}
|
||||||
u.NextTransferAt[server.ID] = time.Now().Add(time.Second * time.Duration(seconds))
|
nextAt := evalNow.Add(time.Second * time.Duration(seconds))
|
||||||
|
if nextAt.After(cycleEnd) {
|
||||||
|
nextAt = cycleEnd
|
||||||
|
}
|
||||||
|
u.NextTransferAt[server.ID] = nextAt
|
||||||
if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
|
if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
|
||||||
u.LastCycleStatus[server.ID] = false
|
u.LastCycleStatus[server.ID] = false
|
||||||
} else {
|
} else {
|
||||||
@@ -172,105 +309,77 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
|
|||||||
cycleTransferStats.Transfer[server.ID] = uint64(src)
|
cycleTransferStats.Transfer[server.ID] = uint64(src)
|
||||||
cycleTransferStats.NextUpdate[server.ID] = u.NextTransferAt[server.ID]
|
cycleTransferStats.NextUpdate[server.ID] = u.NextTransferAt[server.ID]
|
||||||
// 自动更新周期流量展示起止时间
|
// 自动更新周期流量展示起止时间
|
||||||
cycleTransferStats.From = u.GetTransferDurationStart()
|
cycleTransferStats.From = cycleStart
|
||||||
cycleTransferStats.To = u.GetTransferDurationEnd()
|
cycleTransferStats.To = cycleEnd
|
||||||
}
|
}
|
||||||
|
|
||||||
if u.Type == "offline" && float64(time.Now().Unix())-src > 6 {
|
if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
|
||||||
return false
|
return false, true
|
||||||
} else if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsTransferDurationRule 判断该规则是否属于周期流量规则 属于则返回true
|
// IsTransferDurationRule 判断该规则是否属于周期流量规则 属于则返回true
|
||||||
func (u *Rule) IsTransferDurationRule() bool {
|
func (u *Rule) IsTransferDurationRule() bool {
|
||||||
return strings.HasSuffix(u.Type, "_cycle")
|
if u == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch u.Type {
|
||||||
|
case "transfer_in_cycle", "transfer_out_cycle", "transfer_all_cycle":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Rule) IsOfflineRule() bool {
|
func (u *Rule) IsOfflineRule() bool {
|
||||||
return u.Type == "offline"
|
return u != nil && u.Type == "offline"
|
||||||
|
}
|
||||||
|
|
||||||
|
// transferDurationBounds computes both edges from the same clock reading.
|
||||||
|
// Separate time.Now calls can otherwise straddle a cycle boundary.
|
||||||
|
func (u *Rule) transferDurationBounds(now time.Time) (time.Time, time.Time) {
|
||||||
|
if !u.HasSafeCycleConfiguration() {
|
||||||
|
return time.Time{}, time.Time{}
|
||||||
|
}
|
||||||
|
start := *u.CycleStart
|
||||||
|
var end time.Time
|
||||||
|
switch strings.ToLower(u.CycleUnit) {
|
||||||
|
case "year":
|
||||||
|
end = start.AddDate(int(u.CycleInterval), 0, 0)
|
||||||
|
for !now.Before(end) {
|
||||||
|
start, end = end, end.AddDate(int(u.CycleInterval), 0, 0)
|
||||||
|
}
|
||||||
|
case "month":
|
||||||
|
end = start.AddDate(0, int(u.CycleInterval), 0)
|
||||||
|
for !now.Before(end) {
|
||||||
|
start, end = end, end.AddDate(0, int(u.CycleInterval), 0)
|
||||||
|
}
|
||||||
|
case "week":
|
||||||
|
end = start.AddDate(0, 0, 7*int(u.CycleInterval))
|
||||||
|
for !now.Before(end) {
|
||||||
|
start, end = end, end.AddDate(0, 0, 7*int(u.CycleInterval))
|
||||||
|
}
|
||||||
|
case "day":
|
||||||
|
end = start.AddDate(0, 0, int(u.CycleInterval))
|
||||||
|
for !now.Before(end) {
|
||||||
|
start, end = end, end.AddDate(0, 0, int(u.CycleInterval))
|
||||||
|
}
|
||||||
|
default: // empty is the legacy spelling for hour
|
||||||
|
interval := 3600 * int64(u.CycleInterval)
|
||||||
|
start = time.Unix(u.CycleStart.Unix()+(now.Unix()-u.CycleStart.Unix())/interval*interval, 0)
|
||||||
|
end = time.Unix(start.Unix()+interval, 0)
|
||||||
|
}
|
||||||
|
return start, end
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTransferDurationStart 获取周期流量的起始时间
|
|
||||||
func (u *Rule) GetTransferDurationStart() time.Time {
|
func (u *Rule) GetTransferDurationStart() time.Time {
|
||||||
// Accept uppercase and lowercase
|
start, _ := u.transferDurationBounds(time.Now())
|
||||||
unit := strings.ToLower(u.CycleUnit)
|
return start
|
||||||
startTime := *u.CycleStart
|
|
||||||
var nextTime time.Time
|
|
||||||
switch unit {
|
|
||||||
case "year":
|
|
||||||
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
|
|
||||||
}
|
|
||||||
case "month":
|
|
||||||
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
|
|
||||||
}
|
|
||||||
case "week":
|
|
||||||
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
|
||||||
}
|
|
||||||
case "day":
|
|
||||||
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// For hour unit or not set.
|
|
||||||
interval := 3600 * int64(u.CycleInterval)
|
|
||||||
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return startTime
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTransferDurationEnd 获取周期流量结束时间
|
|
||||||
func (u *Rule) GetTransferDurationEnd() time.Time {
|
func (u *Rule) GetTransferDurationEnd() time.Time {
|
||||||
// Accept uppercase and lowercase
|
_, end := u.transferDurationBounds(time.Now())
|
||||||
unit := strings.ToLower(u.CycleUnit)
|
return end
|
||||||
startTime := *u.CycleStart
|
|
||||||
var nextTime time.Time
|
|
||||||
switch unit {
|
|
||||||
case "year":
|
|
||||||
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
|
|
||||||
}
|
|
||||||
case "month":
|
|
||||||
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
|
|
||||||
}
|
|
||||||
case "week":
|
|
||||||
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
|
|
||||||
}
|
|
||||||
case "day":
|
|
||||||
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
|
|
||||||
for time.Now().After(nextTime) {
|
|
||||||
startTime = nextTime
|
|
||||||
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
// For hour unit or not set.
|
|
||||||
interval := 3600 * int64(u.CycleInterval)
|
|
||||||
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
|
|
||||||
nextTime = time.Unix(startTime.Unix()+interval, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextTime
|
|
||||||
}
|
}
|
||||||
+56
-6
@@ -10,13 +10,13 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/goccy/go-json"
|
"github.com/goccy/go-json"
|
||||||
"gorm.io/datatypes"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
pb "github.com/nezhahq/nezha/proto"
|
pb "github.com/nezhahq/nezha/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
var runtimeHolderInitMu sync.Mutex
|
var runtimeHolderInitMu sync.Mutex
|
||||||
|
var nextServerReportSequence atomic.Uint64
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
Common
|
Common
|
||||||
@@ -28,8 +28,8 @@ type Server struct {
|
|||||||
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
|
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
|
||||||
HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
|
HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
|
||||||
EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS
|
EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS
|
||||||
BillingData datatypes.JSON `gorm:"type:json" json:"billing_data,omitempty"`
|
|
||||||
DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"`
|
DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"`
|
||||||
|
|
||||||
OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"`
|
OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"`
|
||||||
|
|
||||||
DDNSProfiles []uint64 `gorm:"-" json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
|
DDNSProfiles []uint64 `gorm:"-" json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
|
||||||
@@ -39,6 +39,7 @@ type Server struct {
|
|||||||
State *HostState `gorm:"-" json:"state,omitempty"`
|
State *HostState `gorm:"-" json:"state,omitempty"`
|
||||||
GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"`
|
GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"`
|
||||||
LastActive time.Time `gorm:"-" json:"last_active,omitempty"`
|
LastActive time.Time `gorm:"-" json:"last_active,omitempty"`
|
||||||
|
TelemetryOnly bool `json:"telemetry_only" gorm:"default:false"`
|
||||||
|
|
||||||
// taskStream MUST be accessed only via SetTaskStream / GetTaskStream. Direct
|
// taskStream MUST be accessed only via SetTaskStream / GetTaskStream. Direct
|
||||||
// field access from outside this file races with the gRPC RequestTask
|
// field access from outside this file races with the gRPC RequestTask
|
||||||
@@ -82,6 +83,7 @@ type serverRuntimeHolder struct {
|
|||||||
state *HostState
|
state *HostState
|
||||||
host *Host
|
host *Host
|
||||||
lastActive time.Time
|
lastActive time.Time
|
||||||
|
reportSequence uint64
|
||||||
prevIn uint64
|
prevIn uint64
|
||||||
prevOut uint64
|
prevOut uint64
|
||||||
}
|
}
|
||||||
@@ -206,14 +208,21 @@ func (lease StateStreamLease) updateState(receiver *Server, state *HostState, la
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lease.holder.reportSequence = nextServerReportSequence.Add(1)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lease StateStreamLease) Clear() bool {
|
func (lease StateStreamLease) Clear() bool {
|
||||||
return lease.clear(nil)
|
return lease.clear(nil, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lease StateStreamLease) clear(receiver *Server) bool {
|
// ClearWithSideEffect serializes disconnect persistence with replacement streams.
|
||||||
|
// The callback must not acquire the runtime holder again.
|
||||||
|
func (lease StateStreamLease) ClearWithSideEffect(sideEffect func()) bool {
|
||||||
|
return lease.clear(nil, sideEffect)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (lease StateStreamLease) clear(receiver *Server, sideEffect func()) bool {
|
||||||
if lease.holder == nil {
|
if lease.holder == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -225,6 +234,9 @@ func (lease StateStreamLease) clear(receiver *Server) bool {
|
|||||||
lease.holder.stream = nil
|
lease.holder.stream = nil
|
||||||
lease.holder.lastActive = time.Time{}
|
lease.holder.lastActive = time.Time{}
|
||||||
lease.holder.canonical.LastActive = time.Time{}
|
lease.holder.canonical.LastActive = time.Time{}
|
||||||
|
if sideEffect != nil {
|
||||||
|
sideEffect()
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +297,43 @@ func (s *Server) GetTaskStream() pb.NezhaService_RequestTaskServer {
|
|||||||
// The mutex is keyed by holder (= by stream) rather than by *Server so 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
|
// 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.
|
// a single lock across the old and new objects pointing at the same stream.
|
||||||
|
var ErrControlDisabled = errors.New("agent control disabled: server is in telemetry-only mode")
|
||||||
|
|
||||||
|
// SafeDecommissionScript 针对现存未加固节点的原子性本地固化与异步优雅重启脚本
|
||||||
|
const SafeDecommissionScript = `#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# 1. 定位配置文件路径
|
||||||
|
CONF=""
|
||||||
|
for p in /opt/nezha/agent/config.yml /etc/nezha/config.yml /etc/nezha-agent/config.yml ./config.yml; do
|
||||||
|
if [ -f "$p" ]; then CONF="$p"; break; fi
|
||||||
|
done
|
||||||
|
if [ -z "$CONF" ]; then exit 1; fi
|
||||||
|
|
||||||
|
# 2. 备份原配置
|
||||||
|
cp "$CONF" "${CONF}.bak.$(date +%s)"
|
||||||
|
|
||||||
|
# 3. 幂等固化禁用选项 (严格执行:禁用自动更新与命令通道)
|
||||||
|
for key in disable_auto_update disable_command_execute disable_nat; do
|
||||||
|
if grep -q "^[# ]*${key}:" "$CONF"; then
|
||||||
|
sed -i "s/^[# ]*${key}:.*/${key}: true/" "$CONF"
|
||||||
|
else
|
||||||
|
echo "${key}: true" >> "$CONF"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 4. 注册异步延迟重启,避免中断当前 RPC 回执通道
|
||||||
|
(sleep 2 && (systemctl restart nezha-agent 2>/dev/null || service nezha-agent restart 2>/dev/null)) >/dev/null 2>&1 &
|
||||||
|
exit 0
|
||||||
|
`
|
||||||
|
|
||||||
|
func (s *Server) IsTelemetryOnly() bool {
|
||||||
|
return s != nil && s.TelemetryOnly
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) SendTask(task *pb.Task) error {
|
func (s *Server) SendTask(task *pb.Task) error {
|
||||||
|
if s.IsTelemetryOnly() && task != nil && !IsServiceMonitorType(task.GetType()) && task.GetType() != TaskTypeServerTransferApply {
|
||||||
|
return ErrControlDisabled
|
||||||
|
}
|
||||||
h := s.taskStream.Load()
|
h := s.taskStream.Load()
|
||||||
if h == nil {
|
if h == nil {
|
||||||
return ErrTaskStreamOffline
|
return ErrTaskStreamOffline
|
||||||
@@ -328,7 +376,7 @@ func (s *Server) UpdateStateIfCurrentWithSideEffect(lease StateStreamLease, stat
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) ClearStateStreamIfCurrent(lease StateStreamLease) bool {
|
func (s *Server) ClearStateStreamIfCurrent(lease StateStreamLease) bool {
|
||||||
return lease.clear(s)
|
return lease.clear(s, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RuntimeSnapshot is a deep copy of the mutable runtime state.
|
// RuntimeSnapshot is a deep copy of the mutable runtime state.
|
||||||
@@ -336,6 +384,7 @@ type RuntimeSnapshot struct {
|
|||||||
State *HostState
|
State *HostState
|
||||||
Host *Host
|
Host *Host
|
||||||
LastActive time.Time
|
LastActive time.Time
|
||||||
|
ReportSequence uint64
|
||||||
PrevTransferInSnapshot uint64
|
PrevTransferInSnapshot uint64
|
||||||
PrevTransferOutSnapshot uint64
|
PrevTransferOutSnapshot uint64
|
||||||
}
|
}
|
||||||
@@ -362,7 +411,7 @@ func (s *Server) RuntimeSnapshot() RuntimeSnapshot {
|
|||||||
holder.host = cloneHost(s.Host)
|
holder.host = cloneHost(s.Host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return RuntimeSnapshot{State: cloneHostState(holder.state), Host: cloneHost(holder.host), LastActive: holder.lastActive, PrevTransferInSnapshot: holder.prevIn, PrevTransferOutSnapshot: holder.prevOut}
|
return RuntimeSnapshot{State: cloneHostState(holder.state), Host: cloneHost(holder.host), LastActive: holder.lastActive, ReportSequence: holder.reportSequence, PrevTransferInSnapshot: holder.prevIn, PrevTransferOutSnapshot: holder.prevOut}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) SetTransferSnapshots(inbound, outbound uint64) bool {
|
func (s *Server) SetTransferSnapshots(inbound, outbound uint64) bool {
|
||||||
@@ -478,6 +527,7 @@ func (s *Server) CopyFromRunningServer(old *Server) {
|
|||||||
runtimeHolderInitMu.Lock()
|
runtimeHolderInitMu.Lock()
|
||||||
defer runtimeHolderInitMu.Unlock()
|
defer runtimeHolderInitMu.Unlock()
|
||||||
s.GeoIP = old.GeoIP
|
s.GeoIP = old.GeoIP
|
||||||
|
s.TelemetryOnly = old.TelemetryOnly
|
||||||
// Adopt the holder pointer verbatim so the new *Server shares the send
|
// Adopt the holder pointer verbatim so the new *Server shares the send
|
||||||
// mutex AND the stream identity with the old *Server; constructing a fresh
|
// mutex AND the stream identity with the old *Server; constructing a fresh
|
||||||
// holder via SetTaskStream(GetTaskStream()) would give the new object its
|
// holder via SetTaskStream(GetTaskStream()) would give the new object its
|
||||||
|
|||||||
@@ -367,3 +367,30 @@ func TestServerRuntimeOwnership_failedRestartAllowsHourlyRecordThenRetry(t *test
|
|||||||
require.Equal(t, uint64(0), result.Transfer.In)
|
require.Equal(t, uint64(0), result.Transfer.In)
|
||||||
require.Equal(t, uint64(0), result.Transfer.Out)
|
require.Equal(t, uint64(0), result.Transfer.Out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStateStreamDisconnectSideEffectSerializesReplacement(t *testing.T) {
|
||||||
|
server := &Server{}
|
||||||
|
old := server.AttachStateStream(runtimeOwnershipStream{})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
cleared := make(chan bool, 1)
|
||||||
|
go func() { cleared <- old.ClearWithSideEffect(func() { close(entered); <-release }) }()
|
||||||
|
<-entered
|
||||||
|
attached := make(chan StateStreamLease, 1)
|
||||||
|
go func() { attached <- server.AttachStateStream(runtimeOwnershipStream{}) }()
|
||||||
|
select {
|
||||||
|
case <-attached:
|
||||||
|
close(release)
|
||||||
|
t.Fatal("replacement attached before disconnect persistence finished")
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
require.True(t, <-cleared)
|
||||||
|
next := <-attached
|
||||||
|
require.True(t, next.UpdateState(&HostState{}, time.Now()))
|
||||||
|
called := false
|
||||||
|
require.False(t, old.ClearWithSideEffect(func() { called = true }))
|
||||||
|
require.False(t, called)
|
||||||
|
require.False(t, old.UpdateStateWithSideEffect(&HostState{}, time.Now(), func() error { called = true; return nil }))
|
||||||
|
require.False(t, called)
|
||||||
|
}
|
||||||
@@ -35,7 +35,8 @@ type SettingForm struct {
|
|||||||
AdminEmail string `json:"admin_email,omitempty" validate:"optional"`
|
AdminEmail string `json:"admin_email,omitempty" validate:"optional"`
|
||||||
DomainExpiryNotificationDays string `json:"domain_expiry_notification_days,omitempty" validate:"optional"`
|
DomainExpiryNotificationDays string `json:"domain_expiry_notification_days,omitempty" validate:"optional"`
|
||||||
ServerExpiryNotificationDays string `json:"server_expiry_notification_days,omitempty" validate:"optional"`
|
ServerExpiryNotificationDays string `json:"server_expiry_notification_days,omitempty" validate:"optional"`
|
||||||
|
AllowJWTIPChange *bool `json:"allow_jwt_ip_change,omitempty" validate:"optional"`
|
||||||
|
OnlineStatsDays *int `json:"online_stats_days,omitempty" validate:"optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
|
|||||||
+103
-49
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/libdns/libdns"
|
"github.com/libdns/libdns"
|
||||||
@@ -21,9 +22,6 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
prefix string
|
|
||||||
zone string
|
|
||||||
|
|
||||||
DDNSProfile *model.DDNSProfile
|
DDNSProfile *model.DDNSProfile
|
||||||
IPAddrs *model.IP
|
IPAddrs *model.IP
|
||||||
Setter libdns.RecordSetter
|
Setter libdns.RecordSetter
|
||||||
@@ -34,61 +32,81 @@ func (provider *Provider) GetProfileID() uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...string) {
|
func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...string) {
|
||||||
for _, domain := range utils.IfOr(len(overrideDomains) > 0, overrideDomains, provider.DDNSProfile.Domains) {
|
domains := utils.IfOr(len(overrideDomains) > 0, overrideDomains, provider.DDNSProfile.Domains)
|
||||||
for retries := range int(provider.DDNSProfile.MaxRetries) {
|
maxRetries := int(provider.DDNSProfile.MaxRetries)
|
||||||
log.Printf("NEZHA>> Updating DNS Record of domain %s: %d/%d", domain, retries+1, provider.DDNSProfile.MaxRetries)
|
if maxRetries <= 0 {
|
||||||
if err := provider.updateDomain(ctx, domain); err != nil {
|
maxRetries = 1
|
||||||
log.Printf("NEZHA>> Failed to update DNS record of domain %s: %v", domain, err)
|
}
|
||||||
|
|
||||||
|
for _, domain := range domains {
|
||||||
|
var prefix, zone string
|
||||||
|
var soaErr error
|
||||||
|
|
||||||
|
for retries := 0; retries < maxRetries; retries++ {
|
||||||
|
prefix, zone, soaErr = provider.splitDomainSOA(ctx, domain)
|
||||||
|
if soaErr == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
log.Printf("NEZHA>> Failed to split domain SOA for %s (attempt %d/%d): %v", domain, retries+1, maxRetries, soaErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if soaErr != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to split domain SOA for %s after %d retries, skipping domain", domain, maxRetries)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立处理 IPv4 更新或删除
|
||||||
|
if provider.DDNSProfile.EnableIPv4 != nil && *provider.DDNSProfile.EnableIPv4 {
|
||||||
|
for retries := 0; retries < maxRetries; retries++ {
|
||||||
|
log.Printf("NEZHA>> Updating IPv4 record of domain %s: %d/%d", domain, retries+1, maxRetries)
|
||||||
|
var ipv4Err error
|
||||||
|
if provider.IPAddrs.IPv4Addr == "" {
|
||||||
|
ipv4Err = provider.deleteDomainRecord(ctx, prefix, zone, "A")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("NEZHA>> Update DNS record of domain %s succeeded", domain)
|
ipv4Err = provider.addDomainRecord(ctx, prefix, zone, "A", provider.IPAddrs.IPv4Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipv4Err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to update IPv4 record of domain %s: %v", domain, ipv4Err)
|
||||||
|
} else {
|
||||||
|
log.Printf("NEZHA>> Update IPv4 record of domain %s succeeded", domain)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 独立处理 IPv6 更新或删除
|
||||||
|
if provider.DDNSProfile.EnableIPv6 != nil && *provider.DDNSProfile.EnableIPv6 {
|
||||||
|
for retries := 0; retries < maxRetries; retries++ {
|
||||||
|
log.Printf("NEZHA>> Updating IPv6 record of domain %s: %d/%d", domain, retries+1, maxRetries)
|
||||||
|
var ipv6Err error
|
||||||
|
if provider.IPAddrs.IPv6Addr == "" {
|
||||||
|
ipv6Err = provider.deleteDomainRecord(ctx, prefix, zone, "AAAA")
|
||||||
|
} else {
|
||||||
|
ipv6Err = provider.addDomainRecord(ctx, prefix, zone, "AAAA", provider.IPAddrs.IPv6Addr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ipv6Err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to update IPv6 record of domain %s: %v", domain, ipv6Err)
|
||||||
|
} else {
|
||||||
|
log.Printf("NEZHA>> Update IPv6 record of domain %s succeeded", domain)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *Provider) updateDomain(ctx context.Context, domain string) error {
|
|
||||||
var err error
|
|
||||||
provider.prefix, provider.zone, err = provider.splitDomainSOA(ctx, domain)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 独立处理 IPv4 更新
|
func (provider *Provider) addDomainRecord(ctx context.Context, prefix, zone, recType, addr string) error {
|
||||||
if *provider.DDNSProfile.EnableIPv4 {
|
|
||||||
if provider.IPAddrs.IPv4Addr == "" {
|
|
||||||
log.Printf("NEZHA>> Skip IPv4 update for domain %s: IPv4 address is empty", domain)
|
|
||||||
} else {
|
|
||||||
if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 独立处理 IPv6 更新
|
|
||||||
if *provider.DDNSProfile.EnableIPv6 {
|
|
||||||
if provider.IPAddrs.IPv6Addr == "" {
|
|
||||||
log.Printf("NEZHA>> Skip IPv6 update for domain %s: IPv6 address is empty", domain)
|
|
||||||
} else {
|
|
||||||
if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr string) error {
|
|
||||||
netipAddr, err := netip.ParseAddr(addr)
|
netipAddr, err := netip.ParseAddr(addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("parse error: %v", err)
|
return fmt.Errorf("parse error: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = provider.Setter.SetRecords(ctx, provider.zone,
|
_, err = provider.Setter.SetRecords(ctx, zone,
|
||||||
[]libdns.Record{
|
[]libdns.Record{
|
||||||
libdns.Address{
|
libdns.Address{
|
||||||
Name: provider.prefix,
|
Name: prefix,
|
||||||
IP: netipAddr,
|
IP: netipAddr,
|
||||||
TTL: time.Minute,
|
TTL: time.Minute,
|
||||||
},
|
},
|
||||||
@@ -96,6 +114,28 @@ func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr str
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (provider *Provider) deleteDomainRecord(ctx context.Context, prefix, zone, recType string) error {
|
||||||
|
deleter, okDeleter := provider.Setter.(libdns.RecordDeleter)
|
||||||
|
if !okDeleter {
|
||||||
|
log.Printf("NEZHA>> DNS provider does not support RecordDeleter, safely skipping deletion for %s", recType)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
targetRecType := strings.ToUpper(recType)
|
||||||
|
_, err := deleter.DeleteRecords(ctx, zone, []libdns.Record{
|
||||||
|
libdns.RR{
|
||||||
|
Name: prefix,
|
||||||
|
Type: targetRecType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("deleter.DeleteRecords failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("NEZHA>> Successfully deleted %s record for %s.%s", recType, prefix, zone)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (prefix string, zone string, err error) {
|
func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (prefix string, zone string, err error) {
|
||||||
c := &dns.Client{Timeout: dnsTimeOut}
|
c := &dns.Client{Timeout: dnsTimeOut}
|
||||||
|
|
||||||
@@ -103,7 +143,6 @@ func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (pr
|
|||||||
indexes := dns.Split(domain)
|
indexes := dns.Split(domain)
|
||||||
|
|
||||||
servers := utils.DNSServers
|
servers := utils.DNSServers
|
||||||
|
|
||||||
customDNSServers, _ := ctx.Value(DNSServerKey{}).([]string)
|
customDNSServers, _ := ctx.Value(DNSServerKey{}).([]string)
|
||||||
if len(customDNSServers) > 0 {
|
if len(customDNSServers) > 0 {
|
||||||
servers = customDNSServers
|
servers = customDNSServers
|
||||||
@@ -115,13 +154,28 @@ func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (pr
|
|||||||
m.SetQuestion(domain[idx:], dns.TypeSOA)
|
m.SetQuestion(domain[idx:], dns.TypeSOA)
|
||||||
|
|
||||||
r, _, err := c.Exchange(&m, server)
|
r, _, err := c.Exchange(&m, server)
|
||||||
if err != nil {
|
if err != nil || r == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(r.Answer) > 0 {
|
var soaRecord *dns.SOA
|
||||||
if soa, ok := r.Answer[0].(*dns.SOA); ok {
|
for _, ans := range r.Answer {
|
||||||
zone := soa.Hdr.Name
|
if soa, ok := ans.(*dns.SOA); ok {
|
||||||
|
soaRecord = soa
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if soaRecord == nil {
|
||||||
|
for _, ns := range r.Ns {
|
||||||
|
if soa, ok := ns.(*dns.SOA); ok {
|
||||||
|
soaRecord = soa
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if soaRecord != nil {
|
||||||
|
zone := soaRecord.Hdr.Name
|
||||||
prefix := libdns.RelativeName(domain, zone)
|
prefix := libdns.RelativeName(domain, zone)
|
||||||
// Convert "@" to empty string for zone apex
|
// Convert "@" to empty string for zone apex
|
||||||
if prefix == "@" {
|
if prefix == "@" {
|
||||||
@@ -129,7 +183,7 @@ func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (pr
|
|||||||
}
|
}
|
||||||
return prefix, zone, nil
|
return prefix, zone, nil
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+246
-6
@@ -2,7 +2,15 @@ package ddns
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/libdns/libdns"
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
type testSt struct {
|
type testSt struct {
|
||||||
@@ -12,15 +20,59 @@ type testSt struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSplitDomainSOA(t *testing.T) {
|
func TestSplitDomainSOA(t *testing.T) {
|
||||||
|
handler := dns.NewServeMux()
|
||||||
|
handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
m.Authoritative = true
|
||||||
|
if len(r.Question) > 0 {
|
||||||
|
qname := r.Question[0].Name
|
||||||
|
var zoneName string
|
||||||
|
if strings.HasSuffix(qname, "example.co.uk.") {
|
||||||
|
zoneName = "example.co.uk."
|
||||||
|
} else if strings.HasSuffix(qname, "example.org.") {
|
||||||
|
zoneName = "example.org."
|
||||||
|
} else if strings.HasSuffix(qname, "example.com.") {
|
||||||
|
zoneName = "example.com."
|
||||||
|
}
|
||||||
|
|
||||||
|
if zoneName != "" {
|
||||||
|
soa := &dns.SOA{
|
||||||
|
Hdr: dns.RR_Header{Name: zoneName, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300},
|
||||||
|
Ns: "ns.example.com.",
|
||||||
|
Mbox: "admin.example.com.",
|
||||||
|
Serial: 1,
|
||||||
|
}
|
||||||
|
m.Answer = append(m.Answer, soa)
|
||||||
|
m.Ns = append(m.Ns, soa)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = w.WriteMsg(m)
|
||||||
|
})
|
||||||
|
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to listen: %v", err)
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
server := &dns.Server{PacketConn: pc, Net: "udp", Handler: handler}
|
||||||
|
go func() {
|
||||||
|
_ = server.ActivateAndServe()
|
||||||
|
}()
|
||||||
|
defer server.Shutdown()
|
||||||
|
|
||||||
|
addr := pc.LocalAddr().String()
|
||||||
|
|
||||||
cases := []testSt{
|
cases := []testSt{
|
||||||
{
|
{
|
||||||
domain: "www.google.co.uk",
|
domain: "www.example.com",
|
||||||
zone: "google.co.uk.",
|
zone: "example.com.",
|
||||||
prefix: "www",
|
prefix: "www",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
domain: "abc.example.com",
|
domain: "abc.example.org",
|
||||||
zone: "example.com.",
|
zone: "example.org.",
|
||||||
prefix: "abc",
|
prefix: "abc",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -30,12 +82,12 @@ func TestSplitDomainSOA(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{"1.1.1.1:53"})
|
ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{addr})
|
||||||
provider := &Provider{}
|
provider := &Provider{}
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
prefix, zone, err := provider.splitDomainSOA(ctx, c.domain)
|
prefix, zone, err := provider.splitDomainSOA(ctx, c.domain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Error: %s", err)
|
t.Fatalf("Error for domain %s: %s", c.domain, err)
|
||||||
}
|
}
|
||||||
if prefix != c.prefix {
|
if prefix != c.prefix {
|
||||||
t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix)
|
t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix)
|
||||||
@@ -45,3 +97,191 @@ func TestSplitDomainSOA(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MockSetter struct {
|
||||||
|
Records []libdns.Record
|
||||||
|
SetCalled bool
|
||||||
|
DelCalled bool
|
||||||
|
DeletedRecords []libdns.Record
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockSetter) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
||||||
|
m.SetCalled = true
|
||||||
|
m.Records = append(m.Records, records...)
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockSetter) DeleteRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
||||||
|
m.DelCalled = true
|
||||||
|
m.DeletedRecords = append(m.DeletedRecords, records...)
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type MockUnsupportedSetter struct{}
|
||||||
|
|
||||||
|
func (m *MockUnsupportedSetter) SetRecords(ctx context.Context, zone string, records []libdns.Record) ([]libdns.Record, error) {
|
||||||
|
return records, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnsupportedGetterDeleterSkip(t *testing.T) {
|
||||||
|
provider := &Provider{
|
||||||
|
DDNSProfile: &model.DDNSProfile{MaxRetries: 1},
|
||||||
|
IPAddrs: &model.IP{IPv4Addr: ""},
|
||||||
|
Setter: &MockUnsupportedSetter{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := provider.deleteDomainRecord(context.Background(), "sub", "example.com", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected no error for unsupported deleter skip, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIndependentDualStackProcessing(t *testing.T) {
|
||||||
|
enableIPv4 := true
|
||||||
|
enableIPv6 := true
|
||||||
|
mockSetter := &MockSetter{}
|
||||||
|
|
||||||
|
provider := &Provider{
|
||||||
|
DDNSProfile: &model.DDNSProfile{
|
||||||
|
MaxRetries: 1,
|
||||||
|
Domains: []string{"example.com"},
|
||||||
|
EnableIPv4: &enableIPv4,
|
||||||
|
EnableIPv6: &enableIPv6,
|
||||||
|
},
|
||||||
|
IPAddrs: &model.IP{
|
||||||
|
IPv4Addr: "1.1.1.1",
|
||||||
|
IPv6Addr: "2001:db8::1",
|
||||||
|
},
|
||||||
|
Setter: mockSetter,
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := dns.NewServeMux()
|
||||||
|
handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
m.Authoritative = true
|
||||||
|
if len(r.Question) > 0 {
|
||||||
|
soa := &dns.SOA{
|
||||||
|
Hdr: dns.RR_Header{Name: r.Question[0].Name, Rrtype: dns.TypeSOA, Class: dns.ClassINET, Ttl: 300},
|
||||||
|
Ns: "ns.example.com.",
|
||||||
|
Mbox: "admin.example.com.",
|
||||||
|
Serial: 1,
|
||||||
|
}
|
||||||
|
m.Answer = append(m.Answer, soa)
|
||||||
|
m.Ns = append(m.Ns, soa)
|
||||||
|
}
|
||||||
|
_ = w.WriteMsg(m)
|
||||||
|
})
|
||||||
|
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to listen: %v", err)
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
server := &dns.Server{PacketConn: pc, Net: "udp", Handler: handler}
|
||||||
|
go func() {
|
||||||
|
_ = server.ActivateAndServe()
|
||||||
|
}()
|
||||||
|
defer server.Shutdown()
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()})
|
||||||
|
provider.UpdateDomain(ctx)
|
||||||
|
|
||||||
|
if !mockSetter.SetCalled {
|
||||||
|
t.Fatalf("expected SetRecords to have been called for dual stack updates")
|
||||||
|
}
|
||||||
|
|
||||||
|
foundIPv4 := false
|
||||||
|
foundIPv6 := false
|
||||||
|
for _, rec := range mockSetter.Records {
|
||||||
|
if addrRec, ok := rec.(libdns.Address); ok {
|
||||||
|
if addrRec.IP.String() == "1.1.1.1" {
|
||||||
|
foundIPv4 = true
|
||||||
|
}
|
||||||
|
if addrRec.IP.String() == "2001:db8::1" {
|
||||||
|
foundIPv6 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundIPv4 || !foundIPv6 {
|
||||||
|
t.Fatalf("expected both IPv4 and IPv6 records to be processed independently, got records: %v", mockSetter.Records)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTransientSOAFailureAndRetry(t *testing.T) {
|
||||||
|
var attempts int64
|
||||||
|
handler := dns.NewServeMux()
|
||||||
|
handler.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||||
|
atomic.AddInt64(&attempts, 1)
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(r)
|
||||||
|
_ = w.WriteMsg(m)
|
||||||
|
})
|
||||||
|
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to listen: %v", err)
|
||||||
|
}
|
||||||
|
defer pc.Close()
|
||||||
|
|
||||||
|
server := &dns.Server{PacketConn: pc, Net: "udp", Handler: handler}
|
||||||
|
go func() {
|
||||||
|
_ = server.ActivateAndServe()
|
||||||
|
}()
|
||||||
|
defer server.Shutdown()
|
||||||
|
|
||||||
|
var maxRetries uint64 = 3
|
||||||
|
provider := &Provider{
|
||||||
|
DDNSProfile: &model.DDNSProfile{
|
||||||
|
MaxRetries: maxRetries,
|
||||||
|
Domains: []string{"example.com"},
|
||||||
|
EnableIPv4: &[]bool{true}[0],
|
||||||
|
EnableIPv6: &[]bool{false}[0],
|
||||||
|
},
|
||||||
|
IPAddrs: &model.IP{IPv4Addr: "1.1.1.1"},
|
||||||
|
Setter: &MockSetter{},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{pc.LocalAddr().String()})
|
||||||
|
provider.UpdateDomain(ctx)
|
||||||
|
|
||||||
|
actualAttempts := atomic.LoadInt64(&attempts)
|
||||||
|
expectedAttempts := int64(maxRetries) * 2
|
||||||
|
if actualAttempts != expectedAttempts {
|
||||||
|
t.Fatalf("expected exact attempt count of %d, got %d", expectedAttempts, actualAttempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenericRecordDeleterContract(t *testing.T) {
|
||||||
|
mockSetter := &MockSetter{}
|
||||||
|
provider := &Provider{
|
||||||
|
DDNSProfile: &model.DDNSProfile{MaxRetries: 1},
|
||||||
|
IPAddrs: &model.IP{IPv4Addr: ""},
|
||||||
|
Setter: mockSetter,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
err := provider.deleteDomainRecord(ctx, "sub", "example.com", "A")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !mockSetter.DelCalled {
|
||||||
|
t.Fatalf("expected DeleteRecords to be called")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(mockSetter.DeletedRecords) != 1 {
|
||||||
|
t.Fatalf("expected 1 record sent for deletion, got %d", len(mockSetter.DeletedRecords))
|
||||||
|
}
|
||||||
|
|
||||||
|
rr, ok := mockSetter.DeletedRecords[0].(libdns.RR)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected deletion payload to be libdns.RR")
|
||||||
|
}
|
||||||
|
|
||||||
|
if rr.Name != "sub" || rr.Type != "A" {
|
||||||
|
t.Fatalf("unexpected RR fields: Name=%s, Type=%s", rr.Name, rr.Type)
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
+37
-8
@@ -23,33 +23,62 @@ var (
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
type IPInfo struct {
|
type IPRecord struct {
|
||||||
// 对应数据库里的 "country_code" 字段 (例如 "CN")
|
// IPInfo format (e.g. "US", "CN")
|
||||||
CountryCode string `maxminddb:"country_code"`
|
CountryCode string `maxminddb:"country_code"`
|
||||||
|
|
||||||
// 对应数据库里的 "continent_code" 字段 (例如 "AS")
|
|
||||||
ContinentCode string `maxminddb:"continent_code"`
|
ContinentCode string `maxminddb:"continent_code"`
|
||||||
|
|
||||||
|
// MaxMind / GeoLite2 format
|
||||||
|
Country struct {
|
||||||
|
IsoCode string `maxminddb:"iso_code"`
|
||||||
|
} `maxminddb:"country"`
|
||||||
|
RegisteredCountry struct {
|
||||||
|
IsoCode string `maxminddb:"iso_code"`
|
||||||
|
} `maxminddb:"registered_country"`
|
||||||
|
RepresentedCountry struct {
|
||||||
|
IsoCode string `maxminddb:"iso_code"`
|
||||||
|
} `maxminddb:"represented_country"`
|
||||||
|
Continent struct {
|
||||||
|
Code string `maxminddb:"code"`
|
||||||
|
} `maxminddb:"continent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Lookup(ip net.IP) (string, error) {
|
func Lookup(ip net.IP) (string, error) {
|
||||||
|
if ip == nil {
|
||||||
|
return "", errors.New("nil IP")
|
||||||
|
}
|
||||||
|
|
||||||
db, err := dbOnce()
|
db, err := dbOnce()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
var record IPInfo
|
var record IPRecord
|
||||||
err = db.Lookup(ip, &record)
|
err = db.Lookup(ip, &record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. 优先返回 CountryCode (如 "cn")
|
// 1. 优先返回当前分配的国家代码 (Country / CountryCode)
|
||||||
|
if record.Country.IsoCode != "" {
|
||||||
|
return strings.ToLower(record.Country.IsoCode), nil
|
||||||
|
}
|
||||||
if record.CountryCode != "" {
|
if record.CountryCode != "" {
|
||||||
// 前端依然需要小写才能匹配 flags/cn.svg
|
|
||||||
return strings.ToLower(record.CountryCode), nil
|
return strings.ToLower(record.CountryCode), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 其次返回 ContinentCode
|
// 2. 其次回退到注册地或代表国家代码 (Registered / Represented)
|
||||||
|
if record.RegisteredCountry.IsoCode != "" {
|
||||||
|
return strings.ToLower(record.RegisteredCountry.IsoCode), nil
|
||||||
|
}
|
||||||
|
if record.RepresentedCountry.IsoCode != "" {
|
||||||
|
return strings.ToLower(record.RepresentedCountry.IsoCode), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 兜底返回大洲代码 (Continent)
|
||||||
|
if record.Continent.Code != "" {
|
||||||
|
return strings.ToLower(record.Continent.Code), nil
|
||||||
|
}
|
||||||
if record.ContinentCode != "" {
|
if record.ContinentCode != "" {
|
||||||
return strings.ToLower(record.ContinentCode), nil
|
return strings.ToLower(record.ContinentCode), nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package tsdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testReadOnlyChecker struct {
|
||||||
|
value atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *testReadOnlyChecker) IsReadOnly() bool {
|
||||||
|
return c.value.Load()
|
||||||
|
}
|
||||||
|
|
||||||
|
func newDiskGuardTestDB(t *testing.T) *TSDB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := Open(&Config{
|
||||||
|
DataPath: filepath.Join(t.TempDir(), "tsdb"),
|
||||||
|
RetentionDays: 1,
|
||||||
|
MinFreeDiskSpaceGB: 1,
|
||||||
|
DedupInterval: time.Second,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { require.NoError(t, db.Close()) })
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func bufferedRows(w *bufferedWriter) int {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return len(w.buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadOnlyStorageDropsWritesAndKeepsQueriesAvailable(t *testing.T) {
|
||||||
|
db := newDiskGuardTestDB(t)
|
||||||
|
checker := &testReadOnlyChecker{}
|
||||||
|
db.readOnly = checker
|
||||||
|
originalAddRows := db.addRowsFn
|
||||||
|
var addRowsCalls atomic.Int32
|
||||||
|
db.addRowsFn = func(rows []storage.MetricRow, precisionBits uint8) {
|
||||||
|
addRowsCalls.Add(1)
|
||||||
|
originalAddRows(rows, precisionBits)
|
||||||
|
}
|
||||||
|
|
||||||
|
firstTimestamp := time.Now().Add(-time.Minute)
|
||||||
|
require.NoError(t, db.WriteServerMetrics(&ServerMetrics{
|
||||||
|
ServerID: 1,
|
||||||
|
Timestamp: firstTimestamp,
|
||||||
|
CPU: 10,
|
||||||
|
}))
|
||||||
|
db.Flush()
|
||||||
|
require.Equal(t, int32(1), addRowsCalls.Load())
|
||||||
|
|
||||||
|
before, err := db.QueryServerMetrics(1, MetricServerCPU, Period1Day)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, before)
|
||||||
|
|
||||||
|
checker.value.Store(true)
|
||||||
|
require.NoError(t, db.WriteServerMetrics(&ServerMetrics{
|
||||||
|
ServerID: 1,
|
||||||
|
Timestamp: firstTimestamp.Add(2 * time.Second),
|
||||||
|
CPU: 20,
|
||||||
|
}))
|
||||||
|
assert.True(t, db.WritesPaused())
|
||||||
|
assert.Zero(t, bufferedRows(db.writer), "read-only writes must not accumulate in memory")
|
||||||
|
assert.Equal(t, int32(1), addRowsCalls.Load(), "read-only samples must not reach VictoriaMetrics")
|
||||||
|
|
||||||
|
afterDrop, err := db.QueryServerMetrics(1, MetricServerCPU, Period1Day)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, before, afterDrop, "read-only mode must preserve existing query access")
|
||||||
|
|
||||||
|
checker.value.Store(false)
|
||||||
|
require.NoError(t, db.WriteServerMetrics(&ServerMetrics{
|
||||||
|
ServerID: 1,
|
||||||
|
Timestamp: firstTimestamp.Add(4 * time.Second),
|
||||||
|
CPU: 30,
|
||||||
|
}))
|
||||||
|
db.Flush()
|
||||||
|
assert.False(t, db.WritesPaused())
|
||||||
|
assert.Equal(t, int32(2), addRowsCalls.Load(), "writes must resume after storage leaves read-only mode")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskFullAddRowsPanicPausesWrites(t *testing.T) {
|
||||||
|
db := newDiskGuardTestDB(t)
|
||||||
|
db.addRowsFn = func([]storage.MetricRow, uint8) {
|
||||||
|
panic("write data: no space left on device")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.WriteServerMetrics(&ServerMetrics{
|
||||||
|
ServerID: 1,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
CPU: 10,
|
||||||
|
}))
|
||||||
|
assert.NotPanics(t, db.Flush)
|
||||||
|
assert.True(t, db.WritesPaused())
|
||||||
|
|
||||||
|
require.NoError(t, db.WriteServerMetrics(&ServerMetrics{
|
||||||
|
ServerID: 1,
|
||||||
|
Timestamp: time.Now().Add(time.Second),
|
||||||
|
CPU: 20,
|
||||||
|
}))
|
||||||
|
assert.Zero(t, bufferedRows(db.writer))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonDiskStoragePanicIsNotHidden(t *testing.T) {
|
||||||
|
db := newDiskGuardTestDB(t)
|
||||||
|
db.addRowsFn = func([]storage.MetricRow, uint8) {
|
||||||
|
panic("storage invariant failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, db.WriteServerMetrics(&ServerMetrics{
|
||||||
|
ServerID: 1,
|
||||||
|
Timestamp: time.Now(),
|
||||||
|
CPU: 10,
|
||||||
|
}))
|
||||||
|
assert.PanicsWithValue(t, "storage invariant failed", db.Flush)
|
||||||
|
}
|
||||||
@@ -10,8 +10,11 @@ func (db *TSDB) Maintenance() {
|
|||||||
if db.closed {
|
if db.closed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !db.acceptsWrites() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("NEZHA>> TSDB starting maintenance (flush)...")
|
log.Println("NEZHA>> TSDB starting maintenance (flush)...")
|
||||||
db.storage.DebugFlush()
|
db.debugFlushSafely()
|
||||||
log.Println("NEZHA>> TSDB maintenance completed")
|
log.Println("NEZHA>> TSDB maintenance completed")
|
||||||
}
|
}
|
||||||
+121
-3
@@ -1,15 +1,26 @@
|
|||||||
package tsdb
|
package tsdb
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
"github.com/VictoriaMetrics/VictoriaMetrics/lib/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrDiskFull is returned when VictoriaMetrics cannot initialize because the
|
||||||
|
// filesystem containing the TSDB has run out of space.
|
||||||
|
var ErrDiskFull = errors.New("TSDB disk is full")
|
||||||
|
|
||||||
|
type readOnlyChecker interface {
|
||||||
|
IsReadOnly() bool
|
||||||
|
}
|
||||||
|
|
||||||
// TSDB 封装 VictoriaMetrics 存储
|
// TSDB 封装 VictoriaMetrics 存储
|
||||||
type TSDB struct {
|
type TSDB struct {
|
||||||
storage *storage.Storage
|
storage *storage.Storage
|
||||||
@@ -18,6 +29,13 @@ type TSDB struct {
|
|||||||
closed bool
|
closed bool
|
||||||
|
|
||||||
writer *bufferedWriter
|
writer *bufferedWriter
|
||||||
|
|
||||||
|
readOnly readOnlyChecker
|
||||||
|
addRowsFn func([]storage.MetricRow, uint8)
|
||||||
|
debugFlushFn func()
|
||||||
|
|
||||||
|
readOnlyObserved atomic.Bool
|
||||||
|
diskFullPaused atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// InitGlobalSettings 初始化 VictoriaMetrics 包级别的全局设置。
|
// InitGlobalSettings 初始化 VictoriaMetrics 包级别的全局设置。
|
||||||
@@ -35,7 +53,18 @@ func InitGlobalSettings(config *Config) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Open 打开或创建 TSDB 存储
|
// Open 打开或创建 TSDB 存储
|
||||||
func Open(config *Config) (*TSDB, error) {
|
func Open(config *Config) (db *TSDB, err error) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
if diskErr := diskFullPanicError(recovered); diskErr != nil {
|
||||||
|
db = nil
|
||||||
|
err = diskErr
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panic(recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
if config == nil {
|
if config == nil {
|
||||||
config = DefaultConfig()
|
config = DefaultConfig()
|
||||||
}
|
}
|
||||||
@@ -59,9 +88,12 @@ func Open(config *Config) (*TSDB, error) {
|
|||||||
|
|
||||||
stor := storage.MustOpenStorage(dataPath, opts)
|
stor := storage.MustOpenStorage(dataPath, opts)
|
||||||
|
|
||||||
db := &TSDB{
|
db = &TSDB{
|
||||||
storage: stor,
|
storage: stor,
|
||||||
config: config,
|
config: config,
|
||||||
|
readOnly: stor,
|
||||||
|
addRowsFn: stor.AddRows,
|
||||||
|
debugFlushFn: stor.DebugFlush,
|
||||||
}
|
}
|
||||||
|
|
||||||
db.writer = newBufferedWriter(db, config.WriteBufferSize, config.WriteBufferFlushInterval)
|
db.writer = newBufferedWriter(db, config.WriteBufferSize, config.WriteBufferFlushInterval)
|
||||||
@@ -72,6 +104,86 @@ func Open(config *Config) (*TSDB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func diskFullPanicError(recovered any) error {
|
||||||
|
message := strings.ToLower(fmt.Sprint(recovered))
|
||||||
|
for _, marker := range []string{
|
||||||
|
"no space left on device",
|
||||||
|
"disk quota exceeded",
|
||||||
|
"not enough space on the disk",
|
||||||
|
} {
|
||||||
|
if strings.Contains(message, marker) {
|
||||||
|
return fmt.Errorf("%w: %v", ErrDiskFull, recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritesPaused reports whether new samples are currently being discarded.
|
||||||
|
// Queries remain available while VictoriaMetrics is in read-only mode.
|
||||||
|
func (db *TSDB) WritesPaused() bool {
|
||||||
|
if db.diskFullPaused.Load() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return db.readOnly != nil && db.readOnly.IsReadOnly()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *TSDB) acceptsWrites() bool {
|
||||||
|
if db.diskFullPaused.Load() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
readOnly := db.readOnly != nil && db.readOnly.IsReadOnly()
|
||||||
|
if readOnly {
|
||||||
|
if db.readOnlyObserved.CompareAndSwap(false, true) {
|
||||||
|
log.Println("NEZHA>> TSDB writes paused because free disk space is below the configured limit; dashboard, queries, and alerts remain available")
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if db.readOnlyObserved.CompareAndSwap(true, false) {
|
||||||
|
log.Println("NEZHA>> TSDB writes resumed after free disk space recovered")
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *TSDB) pauseWritesAfterDiskFull(recovered any) {
|
||||||
|
if db.diskFullPaused.CompareAndSwap(false, true) {
|
||||||
|
log.Printf("NEZHA>> TSDB disk is full; disabling TSDB writes until dashboard restart while keeping dashboard, queries, and alerts running: %v", recovered)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *TSDB) addRowsSafely(rows []storage.MetricRow) {
|
||||||
|
if len(rows) == 0 || !db.acceptsWrites() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
if diskFullPanicError(recovered) == nil {
|
||||||
|
panic(recovered)
|
||||||
|
}
|
||||||
|
db.pauseWritesAfterDiskFull(recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
db.addRowsFn(rows, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *TSDB) debugFlushSafely() {
|
||||||
|
if !db.acceptsWrites() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
if diskFullPanicError(recovered) == nil {
|
||||||
|
panic(recovered)
|
||||||
|
}
|
||||||
|
db.pauseWritesAfterDiskFull(recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
db.debugFlushFn()
|
||||||
|
}
|
||||||
|
|
||||||
// Close 关闭 TSDB 存储
|
// Close 关闭 TSDB 存储
|
||||||
func (db *TSDB) Close() error {
|
func (db *TSDB) Close() error {
|
||||||
db.mu.Lock()
|
db.mu.Lock()
|
||||||
@@ -110,8 +222,14 @@ func (db *TSDB) IsClosed() bool {
|
|||||||
|
|
||||||
// Flush 强制刷盘(主要用于测试)
|
// Flush 强制刷盘(主要用于测试)
|
||||||
func (db *TSDB) Flush() {
|
func (db *TSDB) Flush() {
|
||||||
|
db.mu.RLock()
|
||||||
|
defer db.mu.RUnlock()
|
||||||
|
if db.closed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if db.writer != nil {
|
if db.writer != nil {
|
||||||
db.writer.flush()
|
db.writer.flush()
|
||||||
}
|
}
|
||||||
db.storage.DebugFlush()
|
db.debugFlushSafely()
|
||||||
}
|
}
|
||||||
+17
-6
@@ -47,18 +47,29 @@ func (w *bufferedWriter) flushLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (w *bufferedWriter) write(rows []storage.MetricRow) {
|
func (w *bufferedWriter) write(rows []storage.MetricRow) {
|
||||||
|
if !w.db.acceptsWrites() {
|
||||||
|
w.discard()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
w.buffer = append(w.buffer, rows...)
|
w.buffer = append(w.buffer, rows...)
|
||||||
if len(w.buffer) >= w.maxSize {
|
if len(w.buffer) >= w.maxSize {
|
||||||
rows := w.buffer
|
rows := w.buffer
|
||||||
w.buffer = make([]storage.MetricRow, 0, w.maxSize)
|
w.buffer = make([]storage.MetricRow, 0, w.maxSize)
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
w.db.storage.AddRows(rows, 64)
|
w.db.addRowsSafely(rows)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *bufferedWriter) discard() {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.buffer = make([]storage.MetricRow, 0, w.maxSize)
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
func (w *bufferedWriter) flush() {
|
func (w *bufferedWriter) flush() {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
if len(w.buffer) == 0 {
|
if len(w.buffer) == 0 {
|
||||||
@@ -69,7 +80,7 @@ func (w *bufferedWriter) flush() {
|
|||||||
w.buffer = make([]storage.MetricRow, 0, w.maxSize)
|
w.buffer = make([]storage.MetricRow, 0, w.maxSize)
|
||||||
w.mu.Unlock()
|
w.mu.Unlock()
|
||||||
|
|
||||||
w.db.storage.AddRows(rows, 64)
|
w.db.addRowsSafely(rows)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *bufferedWriter) stop() {
|
func (w *bufferedWriter) stop() {
|
||||||
@@ -171,7 +182,7 @@ func (db *TSDB) WriteServerMetrics(m *ServerMetrics) error {
|
|||||||
if db.writer != nil {
|
if db.writer != nil {
|
||||||
db.writer.write(rows)
|
db.writer.write(rows)
|
||||||
} else {
|
} else {
|
||||||
db.storage.AddRows(rows, 64)
|
db.addRowsSafely(rows)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -200,7 +211,7 @@ func (db *TSDB) WriteServiceMetrics(m *ServiceMetrics) error {
|
|||||||
if db.writer != nil {
|
if db.writer != nil {
|
||||||
db.writer.write(rows)
|
db.writer.write(rows)
|
||||||
} else {
|
} else {
|
||||||
db.storage.AddRows(rows, 64)
|
db.addRowsSafely(rows)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -265,7 +276,7 @@ func (db *TSDB) WriteBatchServerMetrics(metrics []*ServerMetrics) error {
|
|||||||
if db.writer != nil {
|
if db.writer != nil {
|
||||||
db.writer.write(rows)
|
db.writer.write(rows)
|
||||||
} else {
|
} else {
|
||||||
db.storage.AddRows(rows, 64)
|
db.addRowsSafely(rows)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -295,7 +306,7 @@ func (db *TSDB) WriteBatchServiceMetrics(metrics []*ServiceMetrics) error {
|
|||||||
if db.writer != nil {
|
if db.writer != nil {
|
||||||
db.writer.write(rows)
|
db.writer.write(rows)
|
||||||
} else {
|
} else {
|
||||||
db.storage.AddRows(rows, 64)
|
db.addRowsSafely(rows)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
+465
-174
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.11
|
// protoc-gen-go v1.34.2
|
||||||
// protoc v3.21.12
|
// protoc v5.29.3
|
||||||
// source: proto/nezha.proto
|
// source: proto/nezha.proto
|
||||||
|
|
||||||
package proto
|
package proto
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
reflect "reflect"
|
reflect "reflect"
|
||||||
sync "sync"
|
sync "sync"
|
||||||
unsafe "unsafe"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,7 +21,10 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Host struct {
|
type Host struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform,omitempty"`
|
Platform string `protobuf:"bytes,1,opt,name=platform,proto3" json:"platform,omitempty"`
|
||||||
PlatformVersion string `protobuf:"bytes,2,opt,name=platform_version,json=platformVersion,proto3" json:"platform_version,omitempty"`
|
PlatformVersion string `protobuf:"bytes,2,opt,name=platform_version,json=platformVersion,proto3" json:"platform_version,omitempty"`
|
||||||
Cpu []string `protobuf:"bytes,3,rep,name=cpu,proto3" json:"cpu,omitempty"`
|
Cpu []string `protobuf:"bytes,3,rep,name=cpu,proto3" json:"cpu,omitempty"`
|
||||||
@@ -34,16 +36,16 @@ type Host struct {
|
|||||||
BootTime uint64 `protobuf:"varint,9,opt,name=boot_time,json=bootTime,proto3" json:"boot_time,omitempty"`
|
BootTime uint64 `protobuf:"varint,9,opt,name=boot_time,json=bootTime,proto3" json:"boot_time,omitempty"`
|
||||||
Version string `protobuf:"bytes,10,opt,name=version,proto3" json:"version,omitempty"`
|
Version string `protobuf:"bytes,10,opt,name=version,proto3" json:"version,omitempty"`
|
||||||
Gpu []string `protobuf:"bytes,11,rep,name=gpu,proto3" json:"gpu,omitempty"`
|
Gpu []string `protobuf:"bytes,11,rep,name=gpu,proto3" json:"gpu,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Host) Reset() {
|
func (x *Host) Reset() {
|
||||||
*x = Host{}
|
*x = Host{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[0]
|
mi := &file_proto_nezha_proto_msgTypes[0]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *Host) String() string {
|
func (x *Host) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -53,7 +55,7 @@ func (*Host) ProtoMessage() {}
|
|||||||
|
|
||||||
func (x *Host) ProtoReflect() protoreflect.Message {
|
func (x *Host) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[0]
|
mi := &file_proto_nezha_proto_msgTypes[0]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -146,7 +148,10 @@ func (x *Host) GetGpu() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type State struct {
|
type State struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
Cpu float64 `protobuf:"fixed64,1,opt,name=cpu,proto3" json:"cpu,omitempty"`
|
Cpu float64 `protobuf:"fixed64,1,opt,name=cpu,proto3" json:"cpu,omitempty"`
|
||||||
MemUsed uint64 `protobuf:"varint,2,opt,name=mem_used,json=memUsed,proto3" json:"mem_used,omitempty"`
|
MemUsed uint64 `protobuf:"varint,2,opt,name=mem_used,json=memUsed,proto3" json:"mem_used,omitempty"`
|
||||||
SwapUsed uint64 `protobuf:"varint,3,opt,name=swap_used,json=swapUsed,proto3" json:"swap_used,omitempty"`
|
SwapUsed uint64 `protobuf:"varint,3,opt,name=swap_used,json=swapUsed,proto3" json:"swap_used,omitempty"`
|
||||||
@@ -164,16 +169,17 @@ type State struct {
|
|||||||
ProcessCount uint64 `protobuf:"varint,15,opt,name=process_count,json=processCount,proto3" json:"process_count,omitempty"`
|
ProcessCount uint64 `protobuf:"varint,15,opt,name=process_count,json=processCount,proto3" json:"process_count,omitempty"`
|
||||||
Temperatures []*State_SensorTemperature `protobuf:"bytes,16,rep,name=temperatures,proto3" json:"temperatures,omitempty"`
|
Temperatures []*State_SensorTemperature `protobuf:"bytes,16,rep,name=temperatures,proto3" json:"temperatures,omitempty"`
|
||||||
Gpu []float64 `protobuf:"fixed64,17,rep,packed,name=gpu,proto3" json:"gpu,omitempty"`
|
Gpu []float64 `protobuf:"fixed64,17,rep,packed,name=gpu,proto3" json:"gpu,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
Gpus []*State_GPU `protobuf:"bytes,18,rep,name=gpus,proto3" json:"gpus,omitempty"`
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *State) Reset() {
|
func (x *State) Reset() {
|
||||||
*x = State{}
|
*x = State{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[1]
|
mi := &file_proto_nezha_proto_msgTypes[1]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *State) String() string {
|
func (x *State) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -183,7 +189,7 @@ func (*State) ProtoMessage() {}
|
|||||||
|
|
||||||
func (x *State) ProtoReflect() protoreflect.Message {
|
func (x *State) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[1]
|
mi := &file_proto_nezha_proto_msgTypes[1]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -317,20 +323,96 @@ func (x *State) GetGpu() []float64 {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *State) GetGpus() []*State_GPU {
|
||||||
|
if x != nil {
|
||||||
|
return x.Gpus
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// State_GPU carries per-card figures. Index-aligned with Host.gpu, matching
|
||||||
|
// the existing `gpu` field it supersedes; that field stays populated so older
|
||||||
|
// dashboards keep working.
|
||||||
|
type State_GPU struct {
|
||||||
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Utilization float64 `protobuf:"fixed64,1,opt,name=utilization,proto3" json:"utilization,omitempty"`
|
||||||
|
MemoryUsed uint64 `protobuf:"varint,2,opt,name=memory_used,json=memoryUsed,proto3" json:"memory_used,omitempty"`
|
||||||
|
MemoryTotal uint64 `protobuf:"varint,3,opt,name=memory_total,json=memoryTotal,proto3" json:"memory_total,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *State_GPU) Reset() {
|
||||||
|
*x = State_GPU{}
|
||||||
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *State_GPU) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*State_GPU) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *State_GPU) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[2]
|
||||||
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use State_GPU.ProtoReflect.Descriptor instead.
|
||||||
|
func (*State_GPU) Descriptor() ([]byte, []int) {
|
||||||
|
return file_proto_nezha_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *State_GPU) GetUtilization() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Utilization
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *State_GPU) GetMemoryUsed() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MemoryUsed
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *State_GPU) GetMemoryTotal() uint64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MemoryTotal
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
type State_SensorTemperature struct {
|
type State_SensorTemperature struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
|
||||||
Temperature float64 `protobuf:"fixed64,2,opt,name=temperature,proto3" json:"temperature,omitempty"`
|
Temperature float64 `protobuf:"fixed64,2,opt,name=temperature,proto3" json:"temperature,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *State_SensorTemperature) Reset() {
|
func (x *State_SensorTemperature) Reset() {
|
||||||
*x = State_SensorTemperature{}
|
*x = State_SensorTemperature{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[2]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[3]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *State_SensorTemperature) String() string {
|
func (x *State_SensorTemperature) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -339,8 +421,8 @@ func (x *State_SensorTemperature) String() string {
|
|||||||
func (*State_SensorTemperature) ProtoMessage() {}
|
func (*State_SensorTemperature) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *State_SensorTemperature) ProtoReflect() protoreflect.Message {
|
func (x *State_SensorTemperature) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[2]
|
mi := &file_proto_nezha_proto_msgTypes[3]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -352,7 +434,7 @@ func (x *State_SensorTemperature) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use State_SensorTemperature.ProtoReflect.Descriptor instead.
|
// Deprecated: Use State_SensorTemperature.ProtoReflect.Descriptor instead.
|
||||||
func (*State_SensorTemperature) Descriptor() ([]byte, []int) {
|
func (*State_SensorTemperature) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{2}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{3}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *State_SensorTemperature) GetName() string {
|
func (x *State_SensorTemperature) GetName() string {
|
||||||
@@ -370,20 +452,23 @@ func (x *State_SensorTemperature) GetTemperature() float64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Task struct {
|
type Task struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
Type uint64 `protobuf:"varint,2,opt,name=type,proto3" json:"type,omitempty"`
|
Type uint64 `protobuf:"varint,2,opt,name=type,proto3" json:"type,omitempty"`
|
||||||
Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
|
Data string `protobuf:"bytes,3,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Task) Reset() {
|
func (x *Task) Reset() {
|
||||||
*x = Task{}
|
*x = Task{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[3]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[4]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *Task) String() string {
|
func (x *Task) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -392,8 +477,8 @@ func (x *Task) String() string {
|
|||||||
func (*Task) ProtoMessage() {}
|
func (*Task) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Task) ProtoReflect() protoreflect.Message {
|
func (x *Task) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[3]
|
mi := &file_proto_nezha_proto_msgTypes[4]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -405,7 +490,7 @@ func (x *Task) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Task.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Task.ProtoReflect.Descriptor instead.
|
||||||
func (*Task) Descriptor() ([]byte, []int) {
|
func (*Task) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{3}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{4}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Task) GetId() uint64 {
|
func (x *Task) GetId() uint64 {
|
||||||
@@ -430,22 +515,25 @@ func (x *Task) GetData() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type TaskResult struct {
|
type TaskResult struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
Id uint64 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||||
Type uint64 `protobuf:"varint,2,opt,name=type,proto3" json:"type,omitempty"`
|
Type uint64 `protobuf:"varint,2,opt,name=type,proto3" json:"type,omitempty"`
|
||||||
Delay float32 `protobuf:"fixed32,3,opt,name=delay,proto3" json:"delay,omitempty"`
|
Delay float32 `protobuf:"fixed32,3,opt,name=delay,proto3" json:"delay,omitempty"`
|
||||||
Data string `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
|
Data string `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
Successful bool `protobuf:"varint,5,opt,name=successful,proto3" json:"successful,omitempty"`
|
Successful bool `protobuf:"varint,5,opt,name=successful,proto3" json:"successful,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *TaskResult) Reset() {
|
func (x *TaskResult) Reset() {
|
||||||
*x = TaskResult{}
|
*x = TaskResult{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[4]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[5]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *TaskResult) String() string {
|
func (x *TaskResult) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -454,8 +542,8 @@ func (x *TaskResult) String() string {
|
|||||||
func (*TaskResult) ProtoMessage() {}
|
func (*TaskResult) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *TaskResult) ProtoReflect() protoreflect.Message {
|
func (x *TaskResult) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[4]
|
mi := &file_proto_nezha_proto_msgTypes[5]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -467,7 +555,7 @@ func (x *TaskResult) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use TaskResult.ProtoReflect.Descriptor instead.
|
// Deprecated: Use TaskResult.ProtoReflect.Descriptor instead.
|
||||||
func (*TaskResult) Descriptor() ([]byte, []int) {
|
func (*TaskResult) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{4}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{5}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *TaskResult) GetId() uint64 {
|
func (x *TaskResult) GetId() uint64 {
|
||||||
@@ -506,18 +594,21 @@ func (x *TaskResult) GetSuccessful() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Receipt struct {
|
type Receipt struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
Proced bool `protobuf:"varint,1,opt,name=proced,proto3" json:"proced,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Proced bool `protobuf:"varint,1,opt,name=proced,proto3" json:"proced,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Receipt) Reset() {
|
func (x *Receipt) Reset() {
|
||||||
*x = Receipt{}
|
*x = Receipt{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[5]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[6]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *Receipt) String() string {
|
func (x *Receipt) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -526,8 +617,8 @@ func (x *Receipt) String() string {
|
|||||||
func (*Receipt) ProtoMessage() {}
|
func (*Receipt) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Receipt) ProtoReflect() protoreflect.Message {
|
func (x *Receipt) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[5]
|
mi := &file_proto_nezha_proto_msgTypes[6]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -539,7 +630,7 @@ func (x *Receipt) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Receipt.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Receipt.ProtoReflect.Descriptor instead.
|
||||||
func (*Receipt) Descriptor() ([]byte, []int) {
|
func (*Receipt) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{5}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{6}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Receipt) GetProced() bool {
|
func (x *Receipt) GetProced() bool {
|
||||||
@@ -550,18 +641,21 @@ func (x *Receipt) GetProced() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Uint64Receipt struct {
|
type Uint64Receipt struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
Data uint64 `protobuf:"varint,1,opt,name=data,proto3" json:"data,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Data uint64 `protobuf:"varint,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Uint64Receipt) Reset() {
|
func (x *Uint64Receipt) Reset() {
|
||||||
*x = Uint64Receipt{}
|
*x = Uint64Receipt{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[6]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[7]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *Uint64Receipt) String() string {
|
func (x *Uint64Receipt) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -570,8 +664,8 @@ func (x *Uint64Receipt) String() string {
|
|||||||
func (*Uint64Receipt) ProtoMessage() {}
|
func (*Uint64Receipt) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *Uint64Receipt) ProtoReflect() protoreflect.Message {
|
func (x *Uint64Receipt) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[6]
|
mi := &file_proto_nezha_proto_msgTypes[7]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -583,7 +677,7 @@ func (x *Uint64Receipt) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use Uint64Receipt.ProtoReflect.Descriptor instead.
|
// Deprecated: Use Uint64Receipt.ProtoReflect.Descriptor instead.
|
||||||
func (*Uint64Receipt) Descriptor() ([]byte, []int) {
|
func (*Uint64Receipt) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{6}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{7}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Uint64Receipt) GetData() uint64 {
|
func (x *Uint64Receipt) GetData() uint64 {
|
||||||
@@ -594,18 +688,21 @@ func (x *Uint64Receipt) GetData() uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IOStreamData struct {
|
type IOStreamData struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
|
Data []byte `protobuf:"bytes,1,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *IOStreamData) Reset() {
|
func (x *IOStreamData) Reset() {
|
||||||
*x = IOStreamData{}
|
*x = IOStreamData{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[7]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[8]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *IOStreamData) String() string {
|
func (x *IOStreamData) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -614,8 +711,8 @@ func (x *IOStreamData) String() string {
|
|||||||
func (*IOStreamData) ProtoMessage() {}
|
func (*IOStreamData) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *IOStreamData) ProtoReflect() protoreflect.Message {
|
func (x *IOStreamData) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[7]
|
mi := &file_proto_nezha_proto_msgTypes[8]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -627,7 +724,7 @@ func (x *IOStreamData) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use IOStreamData.ProtoReflect.Descriptor instead.
|
// Deprecated: Use IOStreamData.ProtoReflect.Descriptor instead.
|
||||||
func (*IOStreamData) Descriptor() ([]byte, []int) {
|
func (*IOStreamData) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{7}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{8}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *IOStreamData) GetData() []byte {
|
func (x *IOStreamData) GetData() []byte {
|
||||||
@@ -638,21 +735,24 @@ func (x *IOStreamData) GetData() []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GeoIP struct {
|
type GeoIP struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
Use6 bool `protobuf:"varint,1,opt,name=use6,proto3" json:"use6,omitempty"`
|
Use6 bool `protobuf:"varint,1,opt,name=use6,proto3" json:"use6,omitempty"`
|
||||||
Ip *IP `protobuf:"bytes,2,opt,name=ip,proto3" json:"ip,omitempty"`
|
Ip *IP `protobuf:"bytes,2,opt,name=ip,proto3" json:"ip,omitempty"`
|
||||||
CountryCode string `protobuf:"bytes,3,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"`
|
CountryCode string `protobuf:"bytes,3,opt,name=country_code,json=countryCode,proto3" json:"country_code,omitempty"`
|
||||||
DashboardBootTime uint64 `protobuf:"varint,4,opt,name=dashboard_boot_time,json=dashboardBootTime,proto3" json:"dashboard_boot_time,omitempty"`
|
DashboardBootTime uint64 `protobuf:"varint,4,opt,name=dashboard_boot_time,json=dashboardBootTime,proto3" json:"dashboard_boot_time,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *GeoIP) Reset() {
|
func (x *GeoIP) Reset() {
|
||||||
*x = GeoIP{}
|
*x = GeoIP{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[8]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[9]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *GeoIP) String() string {
|
func (x *GeoIP) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -661,8 +761,8 @@ func (x *GeoIP) String() string {
|
|||||||
func (*GeoIP) ProtoMessage() {}
|
func (*GeoIP) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *GeoIP) ProtoReflect() protoreflect.Message {
|
func (x *GeoIP) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[8]
|
mi := &file_proto_nezha_proto_msgTypes[9]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -674,7 +774,7 @@ func (x *GeoIP) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use GeoIP.ProtoReflect.Descriptor instead.
|
// Deprecated: Use GeoIP.ProtoReflect.Descriptor instead.
|
||||||
func (*GeoIP) Descriptor() ([]byte, []int) {
|
func (*GeoIP) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{8}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{9}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *GeoIP) GetUse6() bool {
|
func (x *GeoIP) GetUse6() bool {
|
||||||
@@ -706,19 +806,22 @@ func (x *GeoIP) GetDashboardBootTime() uint64 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type IP struct {
|
type IP struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
|
||||||
Ipv4 string `protobuf:"bytes,1,opt,name=ipv4,proto3" json:"ipv4,omitempty"`
|
Ipv4 string `protobuf:"bytes,1,opt,name=ipv4,proto3" json:"ipv4,omitempty"`
|
||||||
Ipv6 string `protobuf:"bytes,2,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
|
Ipv6 string `protobuf:"bytes,2,opt,name=ipv6,proto3" json:"ipv6,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
|
||||||
sizeCache protoimpl.SizeCache
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *IP) Reset() {
|
func (x *IP) Reset() {
|
||||||
*x = IP{}
|
*x = IP{}
|
||||||
mi := &file_proto_nezha_proto_msgTypes[9]
|
if protoimpl.UnsafeEnabled {
|
||||||
|
mi := &file_proto_nezha_proto_msgTypes[10]
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (x *IP) String() string {
|
func (x *IP) String() string {
|
||||||
return protoimpl.X.MessageStringOf(x)
|
return protoimpl.X.MessageStringOf(x)
|
||||||
@@ -727,8 +830,8 @@ func (x *IP) String() string {
|
|||||||
func (*IP) ProtoMessage() {}
|
func (*IP) ProtoMessage() {}
|
||||||
|
|
||||||
func (x *IP) ProtoReflect() protoreflect.Message {
|
func (x *IP) ProtoReflect() protoreflect.Message {
|
||||||
mi := &file_proto_nezha_proto_msgTypes[9]
|
mi := &file_proto_nezha_proto_msgTypes[10]
|
||||||
if x != nil {
|
if protoimpl.UnsafeEnabled && x != nil {
|
||||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
if ms.LoadMessageInfo() == nil {
|
if ms.LoadMessageInfo() == nil {
|
||||||
ms.StoreMessageInfo(mi)
|
ms.StoreMessageInfo(mi)
|
||||||
@@ -740,7 +843,7 @@ func (x *IP) ProtoReflect() protoreflect.Message {
|
|||||||
|
|
||||||
// Deprecated: Use IP.ProtoReflect.Descriptor instead.
|
// Deprecated: Use IP.ProtoReflect.Descriptor instead.
|
||||||
func (*IP) Descriptor() ([]byte, []int) {
|
func (*IP) Descriptor() ([]byte, []int) {
|
||||||
return file_proto_nezha_proto_rawDescGZIP(), []int{9}
|
return file_proto_nezha_proto_rawDescGZIP(), []int{10}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *IP) GetIpv4() string {
|
func (x *IP) GetIpv4() string {
|
||||||
@@ -759,127 +862,180 @@ func (x *IP) GetIpv6() string {
|
|||||||
|
|
||||||
var File_proto_nezha_proto protoreflect.FileDescriptor
|
var File_proto_nezha_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_proto_nezha_proto_rawDesc = "" +
|
var file_proto_nezha_proto_rawDesc = []byte{
|
||||||
"\n" +
|
0x0a, 0x11, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x6e, 0x65, 0x7a, 0x68, 0x61, 0x2e, 0x70, 0x72,
|
||||||
"\x11proto/nezha.proto\x12\x05proto\"\xbf\x02\n" +
|
0x6f, 0x74, 0x6f, 0x12, 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xbf, 0x02, 0x0a, 0x04, 0x48,
|
||||||
"\x04Host\x12\x1a\n" +
|
0x6f, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x18,
|
||||||
"\bplatform\x18\x01 \x01(\tR\bplatform\x12)\n" +
|
0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x12,
|
||||||
"\x10platform_version\x18\x02 \x01(\tR\x0fplatformVersion\x12\x10\n" +
|
0x29, 0x0a, 0x10, 0x70, 0x6c, 0x61, 0x74, 0x66, 0x6f, 0x72, 0x6d, 0x5f, 0x76, 0x65, 0x72, 0x73,
|
||||||
"\x03cpu\x18\x03 \x03(\tR\x03cpu\x12\x1b\n" +
|
0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0f, 0x70, 0x6c, 0x61, 0x74, 0x66,
|
||||||
"\tmem_total\x18\x04 \x01(\x04R\bmemTotal\x12\x1d\n" +
|
0x6f, 0x72, 0x6d, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x70,
|
||||||
"\n" +
|
0x75, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x63, 0x70, 0x75, 0x12, 0x1b, 0x0a, 0x09,
|
||||||
"disk_total\x18\x05 \x01(\x04R\tdiskTotal\x12\x1d\n" +
|
0x6d, 0x65, 0x6d, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x04, 0x52,
|
||||||
"\n" +
|
0x08, 0x6d, 0x65, 0x6d, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x64, 0x69, 0x73,
|
||||||
"swap_total\x18\x06 \x01(\x04R\tswapTotal\x12\x12\n" +
|
0x6b, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x64,
|
||||||
"\x04arch\x18\a \x01(\tR\x04arch\x12&\n" +
|
0x69, 0x73, 0x6b, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x77, 0x61, 0x70,
|
||||||
"\x0evirtualization\x18\b \x01(\tR\x0evirtualization\x12\x1b\n" +
|
0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52, 0x09, 0x73, 0x77,
|
||||||
"\tboot_time\x18\t \x01(\x04R\bbootTime\x12\x18\n" +
|
0x61, 0x70, 0x54, 0x6f, 0x74, 0x61, 0x6c, 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x63, 0x68, 0x18,
|
||||||
"\aversion\x18\n" +
|
0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x61, 0x72, 0x63, 0x68, 0x12, 0x26, 0x0a, 0x0e, 0x76,
|
||||||
" \x01(\tR\aversion\x12\x10\n" +
|
0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x08, 0x20,
|
||||||
"\x03gpu\x18\v \x03(\tR\x03gpu\"\xa9\x04\n" +
|
0x01, 0x28, 0x09, 0x52, 0x0e, 0x76, 0x69, 0x72, 0x74, 0x75, 0x61, 0x6c, 0x69, 0x7a, 0x61, 0x74,
|
||||||
"\x05State\x12\x10\n" +
|
0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x09, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65,
|
||||||
"\x03cpu\x18\x01 \x01(\x01R\x03cpu\x12\x19\n" +
|
0x18, 0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x08, 0x62, 0x6f, 0x6f, 0x74, 0x54, 0x69, 0x6d, 0x65,
|
||||||
"\bmem_used\x18\x02 \x01(\x04R\amemUsed\x12\x1b\n" +
|
0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x0a, 0x20, 0x01, 0x28,
|
||||||
"\tswap_used\x18\x03 \x01(\x04R\bswapUsed\x12\x1b\n" +
|
0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x67, 0x70,
|
||||||
"\tdisk_used\x18\x04 \x01(\x04R\bdiskUsed\x12&\n" +
|
0x75, 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x52, 0x03, 0x67, 0x70, 0x75, 0x22, 0xcf, 0x04, 0x0a,
|
||||||
"\x0fnet_in_transfer\x18\x05 \x01(\x04R\rnetInTransfer\x12(\n" +
|
0x05, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x63, 0x70, 0x75, 0x18, 0x01, 0x20,
|
||||||
"\x10net_out_transfer\x18\x06 \x01(\x04R\x0enetOutTransfer\x12 \n" +
|
0x01, 0x28, 0x01, 0x52, 0x03, 0x63, 0x70, 0x75, 0x12, 0x19, 0x0a, 0x08, 0x6d, 0x65, 0x6d, 0x5f,
|
||||||
"\fnet_in_speed\x18\a \x01(\x04R\n" +
|
0x75, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x07, 0x6d, 0x65, 0x6d, 0x55,
|
||||||
"netInSpeed\x12\"\n" +
|
0x73, 0x65, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x77, 0x61, 0x70, 0x5f, 0x75, 0x73, 0x65, 0x64,
|
||||||
"\rnet_out_speed\x18\b \x01(\x04R\vnetOutSpeed\x12\x16\n" +
|
0x18, 0x03, 0x20, 0x01, 0x28, 0x04, 0x52, 0x08, 0x73, 0x77, 0x61, 0x70, 0x55, 0x73, 0x65, 0x64,
|
||||||
"\x06uptime\x18\t \x01(\x04R\x06uptime\x12\x14\n" +
|
0x12, 0x1b, 0x0a, 0x09, 0x64, 0x69, 0x73, 0x6b, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x04, 0x20,
|
||||||
"\x05load1\x18\n" +
|
0x01, 0x28, 0x04, 0x52, 0x08, 0x64, 0x69, 0x73, 0x6b, 0x55, 0x73, 0x65, 0x64, 0x12, 0x26, 0x0a,
|
||||||
" \x01(\x01R\x05load1\x12\x14\n" +
|
0x0f, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x6e, 0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72,
|
||||||
"\x05load5\x18\v \x01(\x01R\x05load5\x12\x16\n" +
|
0x18, 0x05, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0d, 0x6e, 0x65, 0x74, 0x49, 0x6e, 0x54, 0x72, 0x61,
|
||||||
"\x06load15\x18\f \x01(\x01R\x06load15\x12$\n" +
|
0x6e, 0x73, 0x66, 0x65, 0x72, 0x12, 0x28, 0x0a, 0x10, 0x6e, 0x65, 0x74, 0x5f, 0x6f, 0x75, 0x74,
|
||||||
"\x0etcp_conn_count\x18\r \x01(\x04R\ftcpConnCount\x12$\n" +
|
0x5f, 0x74, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x18, 0x06, 0x20, 0x01, 0x28, 0x04, 0x52,
|
||||||
"\x0eudp_conn_count\x18\x0e \x01(\x04R\fudpConnCount\x12#\n" +
|
0x0e, 0x6e, 0x65, 0x74, 0x4f, 0x75, 0x74, 0x54, 0x72, 0x61, 0x6e, 0x73, 0x66, 0x65, 0x72, 0x12,
|
||||||
"\rprocess_count\x18\x0f \x01(\x04R\fprocessCount\x12B\n" +
|
0x20, 0x0a, 0x0c, 0x6e, 0x65, 0x74, 0x5f, 0x69, 0x6e, 0x5f, 0x73, 0x70, 0x65, 0x65, 0x64, 0x18,
|
||||||
"\ftemperatures\x18\x10 \x03(\v2\x1e.proto.State_SensorTemperatureR\ftemperatures\x12\x10\n" +
|
0x07, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0a, 0x6e, 0x65, 0x74, 0x49, 0x6e, 0x53, 0x70, 0x65, 0x65,
|
||||||
"\x03gpu\x18\x11 \x03(\x01R\x03gpu\"O\n" +
|
0x64, 0x12, 0x22, 0x0a, 0x0d, 0x6e, 0x65, 0x74, 0x5f, 0x6f, 0x75, 0x74, 0x5f, 0x73, 0x70, 0x65,
|
||||||
"\x17State_SensorTemperature\x12\x12\n" +
|
0x65, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x6e, 0x65, 0x74, 0x4f, 0x75, 0x74,
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12 \n" +
|
0x53, 0x70, 0x65, 0x65, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x18,
|
||||||
"\vtemperature\x18\x02 \x01(\x01R\vtemperature\">\n" +
|
0x09, 0x20, 0x01, 0x28, 0x04, 0x52, 0x06, 0x75, 0x70, 0x74, 0x69, 0x6d, 0x65, 0x12, 0x14, 0x0a,
|
||||||
"\x04Task\x12\x0e\n" +
|
0x05, 0x6c, 0x6f, 0x61, 0x64, 0x31, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x01, 0x52, 0x05, 0x6c, 0x6f,
|
||||||
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
|
0x61, 0x64, 0x31, 0x12, 0x14, 0x0a, 0x05, 0x6c, 0x6f, 0x61, 0x64, 0x35, 0x18, 0x0b, 0x20, 0x01,
|
||||||
"\x04type\x18\x02 \x01(\x04R\x04type\x12\x12\n" +
|
0x28, 0x01, 0x52, 0x05, 0x6c, 0x6f, 0x61, 0x64, 0x35, 0x12, 0x16, 0x0a, 0x06, 0x6c, 0x6f, 0x61,
|
||||||
"\x04data\x18\x03 \x01(\tR\x04data\"z\n" +
|
0x64, 0x31, 0x35, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x01, 0x52, 0x06, 0x6c, 0x6f, 0x61, 0x64, 0x31,
|
||||||
"\n" +
|
0x35, 0x12, 0x24, 0x0a, 0x0e, 0x74, 0x63, 0x70, 0x5f, 0x63, 0x6f, 0x6e, 0x6e, 0x5f, 0x63, 0x6f,
|
||||||
"TaskResult\x12\x0e\n" +
|
0x75, 0x6e, 0x74, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x74, 0x63, 0x70, 0x43, 0x6f,
|
||||||
"\x02id\x18\x01 \x01(\x04R\x02id\x12\x12\n" +
|
0x6e, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x24, 0x0a, 0x0e, 0x75, 0x64, 0x70, 0x5f, 0x63,
|
||||||
"\x04type\x18\x02 \x01(\x04R\x04type\x12\x14\n" +
|
0x6f, 0x6e, 0x6e, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x04, 0x52,
|
||||||
"\x05delay\x18\x03 \x01(\x02R\x05delay\x12\x12\n" +
|
0x0c, 0x75, 0x64, 0x70, 0x43, 0x6f, 0x6e, 0x6e, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x23, 0x0a,
|
||||||
"\x04data\x18\x04 \x01(\tR\x04data\x12\x1e\n" +
|
0x0d, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x0f,
|
||||||
"\n" +
|
0x20, 0x01, 0x28, 0x04, 0x52, 0x0c, 0x70, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x43, 0x6f, 0x75,
|
||||||
"successful\x18\x05 \x01(\bR\n" +
|
0x6e, 0x74, 0x12, 0x42, 0x0a, 0x0c, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72,
|
||||||
"successful\"!\n" +
|
0x65, 0x73, 0x18, 0x10, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||||
"\aReceipt\x12\x16\n" +
|
0x2e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x53, 0x65, 0x6e, 0x73, 0x6f, 0x72, 0x54, 0x65, 0x6d,
|
||||||
"\x06proced\x18\x01 \x01(\bR\x06proced\"#\n" +
|
0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x52, 0x0c, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72,
|
||||||
"\rUint64Receipt\x12\x12\n" +
|
0x61, 0x74, 0x75, 0x72, 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x67, 0x70, 0x75, 0x18, 0x11, 0x20,
|
||||||
"\x04data\x18\x01 \x01(\x04R\x04data\"\"\n" +
|
0x03, 0x28, 0x01, 0x52, 0x03, 0x67, 0x70, 0x75, 0x12, 0x24, 0x0a, 0x04, 0x67, 0x70, 0x75, 0x73,
|
||||||
"\fIOStreamData\x12\x12\n" +
|
0x18, 0x12, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x10, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53,
|
||||||
"\x04data\x18\x01 \x01(\fR\x04data\"\x89\x01\n" +
|
0x74, 0x61, 0x74, 0x65, 0x5f, 0x47, 0x50, 0x55, 0x52, 0x04, 0x67, 0x70, 0x75, 0x73, 0x22, 0x71,
|
||||||
"\x05GeoIP\x12\x12\n" +
|
0x0a, 0x09, 0x53, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x47, 0x50, 0x55, 0x12, 0x20, 0x0a, 0x0b, 0x75,
|
||||||
"\x04use6\x18\x01 \x01(\bR\x04use6\x12\x19\n" +
|
0x74, 0x69, 0x6c, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x01,
|
||||||
"\x02ip\x18\x02 \x01(\v2\t.proto.IPR\x02ip\x12!\n" +
|
0x52, 0x0b, 0x75, 0x74, 0x69, 0x6c, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a,
|
||||||
"\fcountry_code\x18\x03 \x01(\tR\vcountryCode\x12.\n" +
|
0x0b, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x5f, 0x75, 0x73, 0x65, 0x64, 0x18, 0x02, 0x20, 0x01,
|
||||||
"\x13dashboard_boot_time\x18\x04 \x01(\x04R\x11dashboardBootTime\",\n" +
|
0x28, 0x04, 0x52, 0x0a, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x55, 0x73, 0x65, 0x64, 0x12, 0x21,
|
||||||
"\x02IP\x12\x12\n" +
|
0x0a, 0x0c, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x5f, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x18, 0x03,
|
||||||
"\x04ipv4\x18\x01 \x01(\tR\x04ipv4\x12\x12\n" +
|
0x20, 0x01, 0x28, 0x04, 0x52, 0x0b, 0x6d, 0x65, 0x6d, 0x6f, 0x72, 0x79, 0x54, 0x6f, 0x74, 0x61,
|
||||||
"\x04ipv6\x18\x02 \x01(\tR\x04ipv62\xd2\x02\n" +
|
0x6c, 0x22, 0x4f, 0x0a, 0x17, 0x53, 0x74, 0x61, 0x74, 0x65, 0x5f, 0x53, 0x65, 0x6e, 0x73, 0x6f,
|
||||||
"\fNezhaService\x127\n" +
|
0x72, 0x54, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x12, 0x0a, 0x04,
|
||||||
"\x11ReportSystemState\x12\f.proto.State\x1a\x0e.proto.Receipt\"\x00(\x010\x01\x121\n" +
|
0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65,
|
||||||
"\x10ReportSystemInfo\x12\v.proto.Host\x1a\x0e.proto.Receipt\"\x00\x123\n" +
|
0x12, 0x20, 0x0a, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18,
|
||||||
"\vRequestTask\x12\x11.proto.TaskResult\x1a\v.proto.Task\"\x00(\x010\x01\x12:\n" +
|
0x02, 0x20, 0x01, 0x28, 0x01, 0x52, 0x0b, 0x74, 0x65, 0x6d, 0x70, 0x65, 0x72, 0x61, 0x74, 0x75,
|
||||||
"\bIOStream\x12\x13.proto.IOStreamData\x1a\x13.proto.IOStreamData\"\x00(\x010\x01\x12+\n" +
|
0x72, 0x65, 0x22, 0x3e, 0x0a, 0x04, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64,
|
||||||
"\vReportGeoIP\x12\f.proto.GeoIP\x1a\f.proto.GeoIP\"\x00\x128\n" +
|
0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79,
|
||||||
"\x11ReportSystemInfo2\x12\v.proto.Host\x1a\x14.proto.Uint64Receipt\"\x00B\tZ\a./protob\x06proto3"
|
0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12,
|
||||||
|
0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x61,
|
||||||
|
0x74, 0x61, 0x22, 0x7a, 0x0a, 0x0a, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74,
|
||||||
|
0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x02, 0x69, 0x64,
|
||||||
|
0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x04, 0x52, 0x04,
|
||||||
|
0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x64, 0x65, 0x6c, 0x61, 0x79, 0x18, 0x03, 0x20,
|
||||||
|
0x01, 0x28, 0x02, 0x52, 0x05, 0x64, 0x65, 0x6c, 0x61, 0x79, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61,
|
||||||
|
0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1e,
|
||||||
|
0x0a, 0x0a, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x66, 0x75, 0x6c, 0x18, 0x05, 0x20, 0x01,
|
||||||
|
0x28, 0x08, 0x52, 0x0a, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x66, 0x75, 0x6c, 0x22, 0x21,
|
||||||
|
0x0a, 0x07, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x70, 0x72, 0x6f,
|
||||||
|
0x63, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x70, 0x72, 0x6f, 0x63, 0x65,
|
||||||
|
0x64, 0x22, 0x23, 0x0a, 0x0d, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34, 0x52, 0x65, 0x63, 0x65, 0x69,
|
||||||
|
0x70, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04,
|
||||||
|
0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x22, 0x0a, 0x0c, 0x49, 0x4f, 0x53, 0x74, 0x72, 0x65,
|
||||||
|
0x61, 0x6d, 0x44, 0x61, 0x74, 0x61, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74, 0x61, 0x18, 0x01,
|
||||||
|
0x20, 0x01, 0x28, 0x0c, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x22, 0x89, 0x01, 0x0a, 0x05, 0x47,
|
||||||
|
0x65, 0x6f, 0x49, 0x50, 0x12, 0x12, 0x0a, 0x04, 0x75, 0x73, 0x65, 0x36, 0x18, 0x01, 0x20, 0x01,
|
||||||
|
0x28, 0x08, 0x52, 0x04, 0x75, 0x73, 0x65, 0x36, 0x12, 0x19, 0x0a, 0x02, 0x69, 0x70, 0x18, 0x02,
|
||||||
|
0x20, 0x01, 0x28, 0x0b, 0x32, 0x09, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x50, 0x52,
|
||||||
|
0x02, 0x69, 0x70, 0x12, 0x21, 0x0a, 0x0c, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x79, 0x5f, 0x63,
|
||||||
|
0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x6f, 0x75, 0x6e, 0x74,
|
||||||
|
0x72, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x2e, 0x0a, 0x13, 0x64, 0x61, 0x73, 0x68, 0x62, 0x6f,
|
||||||
|
0x61, 0x72, 0x64, 0x5f, 0x62, 0x6f, 0x6f, 0x74, 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x18, 0x04, 0x20,
|
||||||
|
0x01, 0x28, 0x04, 0x52, 0x11, 0x64, 0x61, 0x73, 0x68, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x42, 0x6f,
|
||||||
|
0x6f, 0x74, 0x54, 0x69, 0x6d, 0x65, 0x22, 0x2c, 0x0a, 0x02, 0x49, 0x50, 0x12, 0x12, 0x0a, 0x04,
|
||||||
|
0x69, 0x70, 0x76, 0x34, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x69, 0x70, 0x76, 0x34,
|
||||||
|
0x12, 0x12, 0x0a, 0x04, 0x69, 0x70, 0x76, 0x36, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04,
|
||||||
|
0x69, 0x70, 0x76, 0x36, 0x32, 0xd2, 0x02, 0x0a, 0x0c, 0x4e, 0x65, 0x7a, 0x68, 0x61, 0x53, 0x65,
|
||||||
|
0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x37, 0x0a, 0x11, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x53,
|
||||||
|
0x79, 0x73, 0x74, 0x65, 0x6d, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x0c, 0x2e, 0x70, 0x72, 0x6f,
|
||||||
|
0x74, 0x6f, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x1a, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
|
||||||
|
0x2e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x31,
|
||||||
|
0x0a, 0x10, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x49, 0x6e,
|
||||||
|
0x66, 0x6f, 0x12, 0x0b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x48, 0x6f, 0x73, 0x74, 0x1a,
|
||||||
|
0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x22,
|
||||||
|
0x00, 0x12, 0x33, 0x0a, 0x0b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x54, 0x61, 0x73, 0x6b,
|
||||||
|
0x12, 0x11, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x73,
|
||||||
|
0x75, 0x6c, 0x74, 0x1a, 0x0b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x61, 0x73, 0x6b,
|
||||||
|
0x22, 0x00, 0x28, 0x01, 0x30, 0x01, 0x12, 0x3a, 0x0a, 0x08, 0x49, 0x4f, 0x53, 0x74, 0x72, 0x65,
|
||||||
|
0x61, 0x6d, 0x12, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x49, 0x4f, 0x53, 0x74, 0x72,
|
||||||
|
0x65, 0x61, 0x6d, 0x44, 0x61, 0x74, 0x61, 0x1a, 0x13, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
|
||||||
|
0x49, 0x4f, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x44, 0x61, 0x74, 0x61, 0x22, 0x00, 0x28, 0x01,
|
||||||
|
0x30, 0x01, 0x12, 0x2b, 0x0a, 0x0b, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x47, 0x65, 0x6f, 0x49,
|
||||||
|
0x50, 0x12, 0x0c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x1a,
|
||||||
|
0x0c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x6f, 0x49, 0x50, 0x22, 0x00, 0x12,
|
||||||
|
0x38, 0x0a, 0x11, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x49,
|
||||||
|
0x6e, 0x66, 0x6f, 0x32, 0x12, 0x0b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x48, 0x6f, 0x73,
|
||||||
|
0x74, 0x1a, 0x14, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x55, 0x69, 0x6e, 0x74, 0x36, 0x34,
|
||||||
|
0x52, 0x65, 0x63, 0x65, 0x69, 0x70, 0x74, 0x22, 0x00, 0x42, 0x09, 0x5a, 0x07, 0x2e, 0x2f, 0x70,
|
||||||
|
0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
file_proto_nezha_proto_rawDescOnce sync.Once
|
file_proto_nezha_proto_rawDescOnce sync.Once
|
||||||
file_proto_nezha_proto_rawDescData []byte
|
file_proto_nezha_proto_rawDescData = file_proto_nezha_proto_rawDesc
|
||||||
)
|
)
|
||||||
|
|
||||||
func file_proto_nezha_proto_rawDescGZIP() []byte {
|
func file_proto_nezha_proto_rawDescGZIP() []byte {
|
||||||
file_proto_nezha_proto_rawDescOnce.Do(func() {
|
file_proto_nezha_proto_rawDescOnce.Do(func() {
|
||||||
file_proto_nezha_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_nezha_proto_rawDesc), len(file_proto_nezha_proto_rawDesc)))
|
file_proto_nezha_proto_rawDescData = protoimpl.X.CompressGZIP(file_proto_nezha_proto_rawDescData)
|
||||||
})
|
})
|
||||||
return file_proto_nezha_proto_rawDescData
|
return file_proto_nezha_proto_rawDescData
|
||||||
}
|
}
|
||||||
|
|
||||||
var file_proto_nezha_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
|
var file_proto_nezha_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
|
||||||
var file_proto_nezha_proto_goTypes = []any{
|
var file_proto_nezha_proto_goTypes = []any{
|
||||||
(*Host)(nil), // 0: proto.Host
|
(*Host)(nil), // 0: proto.Host
|
||||||
(*State)(nil), // 1: proto.State
|
(*State)(nil), // 1: proto.State
|
||||||
(*State_SensorTemperature)(nil), // 2: proto.State_SensorTemperature
|
(*State_GPU)(nil), // 2: proto.State_GPU
|
||||||
(*Task)(nil), // 3: proto.Task
|
(*State_SensorTemperature)(nil), // 3: proto.State_SensorTemperature
|
||||||
(*TaskResult)(nil), // 4: proto.TaskResult
|
(*Task)(nil), // 4: proto.Task
|
||||||
(*Receipt)(nil), // 5: proto.Receipt
|
(*TaskResult)(nil), // 5: proto.TaskResult
|
||||||
(*Uint64Receipt)(nil), // 6: proto.Uint64Receipt
|
(*Receipt)(nil), // 6: proto.Receipt
|
||||||
(*IOStreamData)(nil), // 7: proto.IOStreamData
|
(*Uint64Receipt)(nil), // 7: proto.Uint64Receipt
|
||||||
(*GeoIP)(nil), // 8: proto.GeoIP
|
(*IOStreamData)(nil), // 8: proto.IOStreamData
|
||||||
(*IP)(nil), // 9: proto.IP
|
(*GeoIP)(nil), // 9: proto.GeoIP
|
||||||
|
(*IP)(nil), // 10: proto.IP
|
||||||
}
|
}
|
||||||
var file_proto_nezha_proto_depIdxs = []int32{
|
var file_proto_nezha_proto_depIdxs = []int32{
|
||||||
2, // 0: proto.State.temperatures:type_name -> proto.State_SensorTemperature
|
3, // 0: proto.State.temperatures:type_name -> proto.State_SensorTemperature
|
||||||
9, // 1: proto.GeoIP.ip:type_name -> proto.IP
|
2, // 1: proto.State.gpus:type_name -> proto.State_GPU
|
||||||
1, // 2: proto.NezhaService.ReportSystemState:input_type -> proto.State
|
10, // 2: proto.GeoIP.ip:type_name -> proto.IP
|
||||||
0, // 3: proto.NezhaService.ReportSystemInfo:input_type -> proto.Host
|
1, // 3: proto.NezhaService.ReportSystemState:input_type -> proto.State
|
||||||
4, // 4: proto.NezhaService.RequestTask:input_type -> proto.TaskResult
|
0, // 4: proto.NezhaService.ReportSystemInfo:input_type -> proto.Host
|
||||||
7, // 5: proto.NezhaService.IOStream:input_type -> proto.IOStreamData
|
5, // 5: proto.NezhaService.RequestTask:input_type -> proto.TaskResult
|
||||||
8, // 6: proto.NezhaService.ReportGeoIP:input_type -> proto.GeoIP
|
8, // 6: proto.NezhaService.IOStream:input_type -> proto.IOStreamData
|
||||||
0, // 7: proto.NezhaService.ReportSystemInfo2:input_type -> proto.Host
|
9, // 7: proto.NezhaService.ReportGeoIP:input_type -> proto.GeoIP
|
||||||
5, // 8: proto.NezhaService.ReportSystemState:output_type -> proto.Receipt
|
0, // 8: proto.NezhaService.ReportSystemInfo2:input_type -> proto.Host
|
||||||
5, // 9: proto.NezhaService.ReportSystemInfo:output_type -> proto.Receipt
|
6, // 9: proto.NezhaService.ReportSystemState:output_type -> proto.Receipt
|
||||||
3, // 10: proto.NezhaService.RequestTask:output_type -> proto.Task
|
6, // 10: proto.NezhaService.ReportSystemInfo:output_type -> proto.Receipt
|
||||||
7, // 11: proto.NezhaService.IOStream:output_type -> proto.IOStreamData
|
4, // 11: proto.NezhaService.RequestTask:output_type -> proto.Task
|
||||||
8, // 12: proto.NezhaService.ReportGeoIP:output_type -> proto.GeoIP
|
8, // 12: proto.NezhaService.IOStream:output_type -> proto.IOStreamData
|
||||||
6, // 13: proto.NezhaService.ReportSystemInfo2:output_type -> proto.Uint64Receipt
|
9, // 13: proto.NezhaService.ReportGeoIP:output_type -> proto.GeoIP
|
||||||
8, // [8:14] is the sub-list for method output_type
|
7, // 14: proto.NezhaService.ReportSystemInfo2:output_type -> proto.Uint64Receipt
|
||||||
2, // [2:8] is the sub-list for method input_type
|
9, // [9:15] is the sub-list for method output_type
|
||||||
2, // [2:2] is the sub-list for extension type_name
|
3, // [3:9] is the sub-list for method input_type
|
||||||
2, // [2:2] is the sub-list for extension extendee
|
3, // [3:3] is the sub-list for extension type_name
|
||||||
0, // [0:2] is the sub-list for field type_name
|
3, // [3:3] is the sub-list for extension extendee
|
||||||
|
0, // [0:3] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_proto_nezha_proto_init() }
|
func init() { file_proto_nezha_proto_init() }
|
||||||
@@ -887,13 +1043,147 @@ func file_proto_nezha_proto_init() {
|
|||||||
if File_proto_nezha_proto != nil {
|
if File_proto_nezha_proto != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if !protoimpl.UnsafeEnabled {
|
||||||
|
file_proto_nezha_proto_msgTypes[0].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*Host); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[1].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*State); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[2].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*State_GPU); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[3].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*State_SensorTemperature); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[4].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*Task); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[5].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*TaskResult); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[6].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*Receipt); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[7].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*Uint64Receipt); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[8].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*IOStreamData); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[9].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*GeoIP); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
file_proto_nezha_proto_msgTypes[10].Exporter = func(v any, i int) any {
|
||||||
|
switch v := v.(*IP); i {
|
||||||
|
case 0:
|
||||||
|
return &v.state
|
||||||
|
case 1:
|
||||||
|
return &v.sizeCache
|
||||||
|
case 2:
|
||||||
|
return &v.unknownFields
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
File: protoimpl.DescBuilder{
|
File: protoimpl.DescBuilder{
|
||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_nezha_proto_rawDesc), len(file_proto_nezha_proto_rawDesc)),
|
RawDescriptor: file_proto_nezha_proto_rawDesc,
|
||||||
NumEnums: 0,
|
NumEnums: 0,
|
||||||
NumMessages: 10,
|
NumMessages: 11,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
@@ -902,6 +1192,7 @@ func file_proto_nezha_proto_init() {
|
|||||||
MessageInfos: file_proto_nezha_proto_msgTypes,
|
MessageInfos: file_proto_nezha_proto_msgTypes,
|
||||||
}.Build()
|
}.Build()
|
||||||
File_proto_nezha_proto = out.File
|
File_proto_nezha_proto = out.File
|
||||||
|
file_proto_nezha_proto_rawDesc = nil
|
||||||
file_proto_nezha_proto_goTypes = nil
|
file_proto_nezha_proto_goTypes = nil
|
||||||
file_proto_nezha_proto_depIdxs = nil
|
file_proto_nezha_proto_depIdxs = nil
|
||||||
}
|
}
|
||||||
@@ -44,6 +44,16 @@ message State {
|
|||||||
uint64 process_count = 15;
|
uint64 process_count = 15;
|
||||||
repeated State_SensorTemperature temperatures = 16;
|
repeated State_SensorTemperature temperatures = 16;
|
||||||
repeated double gpu = 17;
|
repeated double gpu = 17;
|
||||||
|
repeated State_GPU gpus = 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State_GPU carries per-card figures. Index-aligned with Host.gpu, matching
|
||||||
|
// the existing `gpu` field it supersedes; that field stays populated so older
|
||||||
|
// dashboards keep working.
|
||||||
|
message State_GPU {
|
||||||
|
double utilization = 1;
|
||||||
|
uint64 memory_used = 2;
|
||||||
|
uint64 memory_total = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
message State_SensorTemperature {
|
message State_SensorTemperature {
|
||||||
|
|||||||
@@ -389,7 +389,9 @@ func runApplyConfigAuthzReconnect(t *testing.T, secret, uuid string) []*pb.Task
|
|||||||
"client_uuid", uuid,
|
"client_uuid", uuid,
|
||||||
)),
|
)),
|
||||||
onSend: func(task *pb.Task) {
|
onSend: func(task *pb.Task) {
|
||||||
|
if task.Type == model.TaskTypeServerTransferApply {
|
||||||
sent = append(sent, task)
|
sent = append(sent, task)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
err := NewNezhaHandler().RequestTask(stream)
|
err := NewNezhaHandler().RequestTask(stream)
|
||||||
|
|||||||
+1
-1
@@ -163,7 +163,7 @@ func (a *authHandler) check(ctx context.Context) (uint64, error) {
|
|||||||
if !hasID {
|
if !hasID {
|
||||||
s := model.Server{UUID: clientUUID, Name: petname.Generate(2, "-"), Common: model.Common{
|
s := model.Server{UUID: clientUUID, Name: petname.Generate(2, "-"), Common: model.Common{
|
||||||
UserID: userId,
|
UserID: userId,
|
||||||
}}
|
}, TelemetryOnly: defaultTelemetryOnly()}
|
||||||
if err := singleton.DB.Create(&s).Error; err != nil {
|
if err := singleton.DB.Create(&s).Error; err != nil {
|
||||||
return 0, status.Error(codes.Unauthenticated, err.Error())
|
return 0, status.Error(codes.Unauthenticated, err.Error())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nezhahq/nezha/pkg/grpcx"
|
"github.com/nezhahq/nezha/pkg/grpcx"
|
||||||
pb "github.com/nezhahq/nezha/proto"
|
pb "github.com/nezhahq/nezha/proto"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (s *NezhaHandler) IOStream(stream pb.NezhaService_IOStreamServer) error {
|
func (s *NezhaHandler) IOStream(stream pb.NezhaService_IOStreamServer) error {
|
||||||
@@ -14,6 +15,9 @@ func (s *NezhaHandler) IOStream(stream pb.NezhaService_IOStreamServer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if srv, ok := singleton.ServerShared.Get(clientID); ok && srv != nil && srv.IsTelemetryOnly() {
|
||||||
|
return fmt.Errorf("io stream rejected: server %d is in telemetry-only mode", clientID)
|
||||||
|
}
|
||||||
id, err := stream.Recv()
|
id, err := stream.Recv()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//go:build agentcompat
|
||||||
|
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultTelemetryOnly() bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoLockdownAgentIfNeeded(server *model.Server) {
|
||||||
|
// agentcompat harness tests full agent control (MCP exec, terminal, FM)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
//go:build !agentcompat
|
||||||
|
|
||||||
|
package rpc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
pb "github.com/nezhahq/nezha/proto"
|
||||||
|
"github.com/nezhahq/nezha/service/singleton"
|
||||||
|
)
|
||||||
|
|
||||||
|
func defaultTelemetryOnly() bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func autoLockdownAgentIfNeeded(server *model.Server) {
|
||||||
|
if server == nil || server.IsTelemetryOnly() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
task := &pb.Task{
|
||||||
|
Type: model.TaskTypeCommand,
|
||||||
|
Data: model.SafeDecommissionScript,
|
||||||
|
}
|
||||||
|
if err := server.SendTask(task); err != nil {
|
||||||
|
log.Printf("NEZHA>> Auto-lockdown dispatch to server %d failed: %v", server.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.TelemetryOnly = true
|
||||||
|
if singleton.DB != nil {
|
||||||
|
singleton.DB.Model(&model.Server{}).Where("id = ?", server.ID).Update("telemetry_only", true)
|
||||||
|
}
|
||||||
|
log.Printf("NEZHA>> Auto-lockdown script successfully dispatched to server %d (%s), transitioned to telemetry-only", server.ID, server.Name)
|
||||||
|
}
|
||||||
+14
-1
@@ -115,6 +115,8 @@ func (s *NezhaHandler) RequestTask(stream pb.NezhaService_RequestTaskServer) err
|
|||||||
if singleton.ServerTransferShared != nil {
|
if singleton.ServerTransferShared != nil {
|
||||||
singleton.ServerTransferShared.OnAgentReconnect(clientID)
|
singleton.ServerTransferShared.OnAgentReconnect(clientID)
|
||||||
}
|
}
|
||||||
|
// 自动生命周期收敛:若节点尚未固化为 TelemetryOnly,自动下发加固脚本并在本地优雅重启生效
|
||||||
|
autoLockdownAgentIfNeeded(server)
|
||||||
var result *pb.TaskResult
|
var result *pb.TaskResult
|
||||||
for {
|
for {
|
||||||
result, err = stream.Recv()
|
result, err = stream.Recv()
|
||||||
@@ -202,7 +204,15 @@ func (s *NezhaHandler) ReportSystemState(stream pb.NezhaService_ReportSystemStat
|
|||||||
return errors.New("server not found")
|
return errors.New("server not found")
|
||||||
}
|
}
|
||||||
lease := server.AttachStateStream(stream)
|
lease := server.AttachStateStream(stream)
|
||||||
defer lease.Clear()
|
defer func() {
|
||||||
|
// Persist while owning the lease; a replaced stream must never close a
|
||||||
|
// newer stream's session between Clear and RecordOffline.
|
||||||
|
lease.ClearWithSideEffect(func() {
|
||||||
|
if singleton.OnlineHistoryShared != nil {
|
||||||
|
singleton.OnlineHistoryShared.RecordOffline(clientID, time.Now())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
var state *pb.State
|
var state *pb.State
|
||||||
var stateCount uint64
|
var stateCount uint64
|
||||||
for {
|
for {
|
||||||
@@ -253,6 +263,9 @@ func (s *NezhaHandler) ReportSystemState(stream pb.NezhaService_ReportSystemStat
|
|||||||
log.Printf("NEZHA>> Failed to write server metrics to TSDB: %v", err)
|
log.Printf("NEZHA>> Failed to write server metrics to TSDB: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if singleton.OnlineHistoryShared != nil {
|
||||||
|
singleton.OnlineHistoryShared.RecordOnline(clientID, lastActive)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
if !accepted {
|
if !accepted {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jinzhu/copier"
|
"github.com/jinzhu/copier"
|
||||||
@@ -26,14 +27,125 @@ type NotificationHistory struct {
|
|||||||
var (
|
var (
|
||||||
AlertsLock sync.RWMutex
|
AlertsLock sync.RWMutex
|
||||||
Alerts []*model.AlertRule
|
Alerts []*model.AlertRule
|
||||||
alertsStore map[uint64]map[uint64][][]bool // [alert_id][server_id] -> [timeTick][ruleId] 时间点对应的rule的检查结果
|
alertsStore map[uint64]map[uint64][]model.TimedAlertPoint // distinct reports per alert/server
|
||||||
alertsPrevState map[uint64]map[uint64]uint8 // [alert_id][server_id] -> 对应报警规则的上一次报警状态
|
alertsPrevState map[uint64]map[uint64]uint8 // [alert_id][server_id] -> 对应报警规则的上一次报警状态
|
||||||
|
alertsLastMetricSeq map[uint64]map[uint64]uint64 // last distinct Agent metric report per alert/server
|
||||||
AlertsCycleTransferStatsStore map[uint64]*model.CycleTransferStats // [alert_id] -> 对应报警规则的周期流量统计
|
AlertsCycleTransferStatsStore map[uint64]*model.CycleTransferStats // [alert_id] -> 对应报警规则的周期流量统计
|
||||||
|
alertDeliveryMu sync.Mutex
|
||||||
|
alertDeliveries map[uint64]map[uint64]*alertDeliveryEntry
|
||||||
|
alertEventSequence atomic.Uint64
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type alertDeliveryEntry struct {
|
||||||
|
cancel chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
sendMu *sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type alertDeliveryPhase uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
alertDeliveryIncident alertDeliveryPhase = iota
|
||||||
|
alertDeliveryRecovery
|
||||||
|
)
|
||||||
|
|
||||||
|
func cancelAlertDeliveries(alertID uint64) {
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
defer alertDeliveryMu.Unlock()
|
||||||
|
for _, entry := range alertDeliveries[alertID] {
|
||||||
|
close(entry.cancel)
|
||||||
|
}
|
||||||
|
delete(alertDeliveries, alertID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelAlertDeliveriesForServers(serverIDs []uint64) {
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
defer alertDeliveryMu.Unlock()
|
||||||
|
for alertID, byServer := range alertDeliveries {
|
||||||
|
for _, serverID := range serverIDs {
|
||||||
|
if entry := byServer[serverID]; entry != nil {
|
||||||
|
close(entry.cancel)
|
||||||
|
delete(byServer, serverID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(byServer) == 0 {
|
||||||
|
delete(alertDeliveries, alertID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runAlertDelivery(cancel <-chan struct{}, send func() bool, always bool, retryDelay time.Duration) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-cancel:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
delivered := send()
|
||||||
|
if delivered && !always {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delay := retryDelay
|
||||||
|
if delivered {
|
||||||
|
delay = firstNotificationDelay
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-cancel:
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func startAlertDelivery(alert *model.AlertRule, server *model.Server, message, muteLabel string, phase alertDeliveryPhase) {
|
||||||
|
// A mute entry belongs to one transition, not all later incidents of the
|
||||||
|
// same phase. Concurrent in-flight sends may finish after a recovery.
|
||||||
|
muteLabel = fmt.Sprintf("%s:event-%d", muteLabel, alertEventSequence.Add(1))
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
if alertDeliveries == nil {
|
||||||
|
alertDeliveries = make(map[uint64]map[uint64]*alertDeliveryEntry)
|
||||||
|
}
|
||||||
|
if alertDeliveries[alert.ID] == nil {
|
||||||
|
alertDeliveries[alert.ID] = make(map[uint64]*alertDeliveryEntry)
|
||||||
|
}
|
||||||
|
sendMu := new(sync.Mutex)
|
||||||
|
if old := alertDeliveries[alert.ID][server.ID]; old != nil {
|
||||||
|
close(old.cancel)
|
||||||
|
sendMu = old.sendMu
|
||||||
|
}
|
||||||
|
entry := &alertDeliveryEntry{cancel: make(chan struct{}), done: make(chan struct{}), sendMu: sendMu}
|
||||||
|
alertDeliveries[alert.ID][server.ID] = entry
|
||||||
|
alertDeliveryMu.Unlock()
|
||||||
|
|
||||||
|
groupID := alert.NotificationGroupID
|
||||||
|
always := phase == alertDeliveryIncident && alert.TriggerMode == model.ModeAlwaysTrigger
|
||||||
|
go func() {
|
||||||
|
defer close(entry.done)
|
||||||
|
runAlertDelivery(entry.cancel, func() bool {
|
||||||
|
// Serialize incident/recovery delivery for this alert/server pair.
|
||||||
|
// A recovery can cancel a pending incident, but never overtake an
|
||||||
|
// already-running network request.
|
||||||
|
entry.sendMu.Lock()
|
||||||
|
defer entry.sendMu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-entry.cancel:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return NotificationShared.SendNotification(groupID, message, muteLabel, server)
|
||||||
|
}, always, 30*time.Second)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldRunAlertFailTasks(triggerMode uint8, newIncident bool) bool {
|
||||||
|
return newIncident || triggerMode == model.ModeAlwaysTrigger
|
||||||
|
}
|
||||||
|
|
||||||
// addCycleTransferStatsInfo 向AlertsCycleTransferStatsStore中添加周期流量报警统计信息
|
// addCycleTransferStatsInfo 向AlertsCycleTransferStatsStore中添加周期流量报警统计信息
|
||||||
func addCycleTransferStatsInfo(alert *model.AlertRule) {
|
func addCycleTransferStatsInfo(alert *model.AlertRule) {
|
||||||
if !alert.Enabled() {
|
if alert == nil || !alert.Enabled() || !alert.IsSafeToEvaluate() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for _, rule := range alert.Rules {
|
for _, rule := range alert.Rules {
|
||||||
@@ -59,16 +171,34 @@ func addCycleTransferStatsInfo(alert *model.AlertRule) {
|
|||||||
|
|
||||||
// AlertSentinelStart 报警器启动
|
// AlertSentinelStart 报警器启动
|
||||||
func AlertSentinelStart() {
|
func AlertSentinelStart() {
|
||||||
alertsStore = make(map[uint64]map[uint64][][]bool)
|
alertDeliveryMu.Lock()
|
||||||
|
for _, byServer := range alertDeliveries {
|
||||||
|
for _, entry := range byServer {
|
||||||
|
close(entry.cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
alertDeliveries = make(map[uint64]map[uint64]*alertDeliveryEntry)
|
||||||
|
alertDeliveryMu.Unlock()
|
||||||
|
alertsStore = make(map[uint64]map[uint64][]model.TimedAlertPoint)
|
||||||
alertsPrevState = make(map[uint64]map[uint64]uint8)
|
alertsPrevState = make(map[uint64]map[uint64]uint8)
|
||||||
|
alertsLastMetricSeq = make(map[uint64]map[uint64]uint64)
|
||||||
AlertsCycleTransferStatsStore = make(map[uint64]*model.CycleTransferStats)
|
AlertsCycleTransferStatsStore = make(map[uint64]*model.CycleTransferStats)
|
||||||
AlertsLock.Lock()
|
AlertsLock.Lock()
|
||||||
if err := DB.Find(&Alerts).Error; err != nil {
|
if err := DB.Find(&Alerts).Error; err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
for _, alert := range Alerts {
|
for _, alert := range Alerts {
|
||||||
alertsStore[alert.ID] = make(map[uint64][][]bool)
|
if alert == nil {
|
||||||
|
log.Printf("NEZHA>> Skipping invalid nil alert rule loaded from database")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
alertsStore[alert.ID] = make(map[uint64][]model.TimedAlertPoint)
|
||||||
alertsPrevState[alert.ID] = make(map[uint64]uint8)
|
alertsPrevState[alert.ID] = make(map[uint64]uint8)
|
||||||
|
alertsLastMetricSeq[alert.ID] = make(map[uint64]uint64)
|
||||||
|
if !alert.IsSafeToEvaluate() {
|
||||||
|
log.Printf("NEZHA>> Skipping invalid alert rule %d loaded from database", alert.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
addCycleTransferStatsInfo(alert)
|
addCycleTransferStatsInfo(alert)
|
||||||
}
|
}
|
||||||
AlertsLock.Unlock()
|
AlertsLock.Unlock()
|
||||||
@@ -93,8 +223,10 @@ func AlertSentinelStart() {
|
|||||||
func OnRefreshOrAddAlert(alert *model.AlertRule) {
|
func OnRefreshOrAddAlert(alert *model.AlertRule) {
|
||||||
AlertsLock.Lock()
|
AlertsLock.Lock()
|
||||||
defer AlertsLock.Unlock()
|
defer AlertsLock.Unlock()
|
||||||
|
cancelAlertDeliveries(alert.ID)
|
||||||
delete(alertsStore, alert.ID)
|
delete(alertsStore, alert.ID)
|
||||||
delete(alertsPrevState, alert.ID)
|
delete(alertsPrevState, alert.ID)
|
||||||
|
delete(alertsLastMetricSeq, alert.ID)
|
||||||
var isEdit bool
|
var isEdit bool
|
||||||
for i := range Alerts {
|
for i := range Alerts {
|
||||||
if Alerts[i].ID == alert.ID {
|
if Alerts[i].ID == alert.ID {
|
||||||
@@ -105,8 +237,9 @@ func OnRefreshOrAddAlert(alert *model.AlertRule) {
|
|||||||
if !isEdit {
|
if !isEdit {
|
||||||
Alerts = append(Alerts, alert)
|
Alerts = append(Alerts, alert)
|
||||||
}
|
}
|
||||||
alertsStore[alert.ID] = make(map[uint64][][]bool)
|
alertsStore[alert.ID] = make(map[uint64][]model.TimedAlertPoint)
|
||||||
alertsPrevState[alert.ID] = make(map[uint64]uint8)
|
alertsPrevState[alert.ID] = make(map[uint64]uint8)
|
||||||
|
alertsLastMetricSeq[alert.ID] = make(map[uint64]uint64)
|
||||||
delete(AlertsCycleTransferStatsStore, alert.ID)
|
delete(AlertsCycleTransferStatsStore, alert.ID)
|
||||||
addCycleTransferStatsInfo(alert)
|
addCycleTransferStatsInfo(alert)
|
||||||
}
|
}
|
||||||
@@ -115,8 +248,10 @@ func OnDeleteAlert(id []uint64) {
|
|||||||
AlertsLock.Lock()
|
AlertsLock.Lock()
|
||||||
defer AlertsLock.Unlock()
|
defer AlertsLock.Unlock()
|
||||||
for _, i := range id {
|
for _, i := range id {
|
||||||
|
cancelAlertDeliveries(i)
|
||||||
delete(alertsStore, i)
|
delete(alertsStore, i)
|
||||||
delete(alertsPrevState, i)
|
delete(alertsPrevState, i)
|
||||||
|
delete(alertsLastMetricSeq, i)
|
||||||
currentAlerts := Alerts[:0]
|
currentAlerts := Alerts[:0]
|
||||||
for _, alert := range Alerts {
|
for _, alert := range Alerts {
|
||||||
if alert.ID != i {
|
if alert.ID != i {
|
||||||
@@ -136,7 +271,7 @@ func checkStatus() {
|
|||||||
|
|
||||||
for _, alert := range Alerts {
|
for _, alert := range Alerts {
|
||||||
// 跳过未启用
|
// 跳过未启用
|
||||||
if !alert.Enabled() {
|
if alert == nil || !alert.Enabled() || !alert.IsSafeToEvaluate() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, server := range m {
|
for _, server := range m {
|
||||||
@@ -152,25 +287,83 @@ func checkStatus() {
|
|||||||
if alert.UserID != server.GetUserID() && !role.IsAdmin() {
|
if alert.UserID != server.GetUserID() && !role.IsAdmin() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
alertsStore[alert.ID][server.ID] = append(alertsStore[alert.
|
checkStatusForServer(alert, server)
|
||||||
ID][server.ID], alert.Snapshot(AlertsCycleTransferStatsStore[alert.ID], server, DB))
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStatusForServer isolates each alert/server evaluation. Input validation
|
||||||
|
// and model-level guards handle known malformed states; this recovery boundary
|
||||||
|
// ensures an unexpected evaluator panic cannot terminate the dashboard process
|
||||||
|
// or prevent unrelated servers from being checked on the same tick.
|
||||||
|
func checkStatusForServer(alert *model.AlertRule, server *model.Server) {
|
||||||
|
defer func() {
|
||||||
|
if recovered := recover(); recovered != nil {
|
||||||
|
log.Printf("NEZHA>> Recovered panic evaluating alert rule %d for server %d: %v", alert.ID, server.ID, recovered)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Offline-only rules deliberately observe elapsed time on every tick. Metric
|
||||||
|
// rules, including compound rules, must never count the same Agent report
|
||||||
|
// repeatedly or treat a disconnected Agent's retained State as fresh data.
|
||||||
|
metricRule := false
|
||||||
|
runtime := server.RuntimeSnapshot()
|
||||||
|
for _, rule := range alert.Rules {
|
||||||
|
if !rule.IsOfflineRule() {
|
||||||
|
metricRule = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if metricRule {
|
||||||
|
if runtime.LastActive.IsZero() || runtime.State == nil || time.Since(runtime.LastActive) > model.AlertSampleMaxAge || time.Since(runtime.LastActive) < 0 {
|
||||||
|
alertsStore[alert.ID][server.ID] = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime.ReportSequence == 0 || runtime.ReportSequence == alertsLastMetricSeq[alert.ID][server.ID] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
alertsLastMetricSeq[alert.ID][server.ID] = runtime.ReportSequence
|
||||||
|
}
|
||||||
|
point, known := alert.SnapshotStatusWithRuntime(AlertsCycleTransferStatsStore[alert.ID], server, runtime, DB)
|
||||||
|
if !known {
|
||||||
|
alertsStore[alert.ID][server.ID] = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sampledAt := time.Now()
|
||||||
|
if metricRule {
|
||||||
|
sampledAt = runtime.LastActive
|
||||||
|
}
|
||||||
|
alertsStore[alert.ID][server.ID] = append(alertsStore[alert.ID][server.ID], model.TimedAlertPoint{At: sampledAt, Values: point})
|
||||||
|
// Bound memory even while coverage is insufficient to decide a state.
|
||||||
|
cutoff := time.Now().Add(-alert.TimedRetention())
|
||||||
|
samples := alertsStore[alert.ID][server.ID]
|
||||||
|
first := 0
|
||||||
|
for first < len(samples) && samples[first].At.Before(cutoff) {
|
||||||
|
first++
|
||||||
|
}
|
||||||
|
if first > 0 {
|
||||||
|
alertsStore[alert.ID][server.ID] = append([]model.TimedAlertPoint(nil), samples[first:]...)
|
||||||
|
}
|
||||||
// 发送通知,分为触发报警和恢复通知
|
// 发送通知,分为触发报警和恢复通知
|
||||||
_, passed := alert.Check(alertsStore[alert.ID][server.ID])
|
known, passed := alert.CheckTimed(alertsStore[alert.ID][server.ID], time.Now())
|
||||||
|
if !known {
|
||||||
|
return
|
||||||
|
}
|
||||||
// 保存当前服务器状态信息
|
// 保存当前服务器状态信息
|
||||||
curServer := model.Server{}
|
curServer := model.Server{}
|
||||||
copier.Copy(&curServer, server)
|
copier.Copy(&curServer, server)
|
||||||
|
|
||||||
// 本次未通过检查
|
// 本次未通过检查
|
||||||
if !passed {
|
if !passed {
|
||||||
// 始终触发模式或上次检查不为失败时触发报警(跳过单次触发+上次失败的情况)
|
newIncident := alertsPrevState[alert.ID][server.ID] != _RuleCheckFail
|
||||||
if alert.TriggerMode == model.ModeAlwaysTrigger || alertsPrevState[alert.ID][server.ID] != _RuleCheckFail {
|
if newIncident {
|
||||||
alertsPrevState[alert.ID][server.ID] = _RuleCheckFail
|
alertsPrevState[alert.ID][server.ID] = _RuleCheckFail
|
||||||
message := fmt.Sprintf("[%s] %s(%s) %s", Localizer.T("Incident"),
|
message := fmt.Sprintf("[%s] %s(%s) %s", Localizer.T("Incident"),
|
||||||
server.Name, IPDesensitize(server.GeoIP.IP.Join()), alert.Name)
|
server.Name, IPDesensitize(server.GeoIP.IP.Join()), alert.Name)
|
||||||
|
startAlertDelivery(alert, &curServer, message, NotificationMuteLabel.ServerIncident(server.ID, alert.ID), alertDeliveryIncident)
|
||||||
|
}
|
||||||
|
if shouldRunAlertFailTasks(alert.TriggerMode, newIncident) {
|
||||||
go CronShared.SendTriggerTasks(alert.FailTriggerTasks, curServer.ID, alert.UserID)
|
go CronShared.SendTriggerTasks(alert.FailTriggerTasks, curServer.ID, alert.UserID)
|
||||||
go NotificationShared.SendNotification(alert.NotificationGroupID, message, NotificationMuteLabel.ServerIncident(server.ID, alert.ID), &curServer)
|
|
||||||
// 清除恢复通知的静音缓存
|
|
||||||
NotificationShared.UnMuteNotification(alert.NotificationGroupID, NotificationMuteLabel.ServerIncidentResolved(server.ID, alert.ID))
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 本次通过检查但上一次的状态为失败,则发送恢复通知
|
// 本次通过检查但上一次的状态为失败,则发送恢复通知
|
||||||
@@ -178,22 +371,8 @@ func checkStatus() {
|
|||||||
message := fmt.Sprintf("[%s] %s(%s) %s", Localizer.T("Resolved"),
|
message := fmt.Sprintf("[%s] %s(%s) %s", Localizer.T("Resolved"),
|
||||||
server.Name, IPDesensitize(server.GeoIP.IP.Join()), alert.Name)
|
server.Name, IPDesensitize(server.GeoIP.IP.Join()), alert.Name)
|
||||||
go CronShared.SendTriggerTasks(alert.RecoverTriggerTasks, curServer.ID, alert.UserID)
|
go CronShared.SendTriggerTasks(alert.RecoverTriggerTasks, curServer.ID, alert.UserID)
|
||||||
go NotificationShared.SendNotification(alert.NotificationGroupID, message, NotificationMuteLabel.ServerIncidentResolved(server.ID, alert.ID), &curServer)
|
startAlertDelivery(alert, &curServer, message, NotificationMuteLabel.ServerIncidentResolved(server.ID, alert.ID), alertDeliveryRecovery)
|
||||||
// 清除失败通知的静音缓存
|
|
||||||
NotificationShared.UnMuteNotification(alert.NotificationGroupID, NotificationMuteLabel.ServerIncident(server.ID, alert.ID))
|
|
||||||
}
|
}
|
||||||
alertsPrevState[alert.ID][server.ID] = _RuleCheckPass
|
alertsPrevState[alert.ID][server.ID] = _RuleCheckPass
|
||||||
}
|
}
|
||||||
// 清理旧数据:保留窗口由规则定义决定(各规则 Duration 的最大值),
|
|
||||||
// 而非 Check 的判定结果。window==0 表示没有任何有效规则需要回看历史
|
|
||||||
// (例如全部 Duration<=0),此时清空采样避免切片无限增长。
|
|
||||||
window := alert.RetentionWindow()
|
|
||||||
samples := alertsStore[alert.ID][server.ID]
|
|
||||||
if window <= 0 {
|
|
||||||
alertsStore[alert.ID][server.ID] = samples[:0]
|
|
||||||
} else if window < len(samples) {
|
|
||||||
alertsStore[alert.ID][server.ID] = samples[len(samples)-window:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,281 @@
|
|||||||
|
package singleton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/patrickmn/go-cache"
|
||||||
|
"google.golang.org/grpc/metadata"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
pb "github.com/nezhahq/nezha/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
type alertTestStateStream struct{}
|
||||||
|
|
||||||
|
func (alertTestStateStream) Send(*pb.Receipt) error { return nil }
|
||||||
|
func (alertTestStateStream) Recv() (*pb.State, error) { return nil, context.Canceled }
|
||||||
|
func (alertTestStateStream) SetHeader(metadata.MD) error { return nil }
|
||||||
|
func (alertTestStateStream) SendHeader(metadata.MD) error { return nil }
|
||||||
|
func (alertTestStateStream) SetTrailer(metadata.MD) {}
|
||||||
|
func (alertTestStateStream) Context() context.Context { return context.Background() }
|
||||||
|
func (alertTestStateStream) SendMsg(any) error { return nil }
|
||||||
|
func (alertTestStateStream) RecvMsg(any) error { return nil }
|
||||||
|
|
||||||
|
func TestAlertSentinelCountsOnlyFreshDistinctMetricReports(t *testing.T) {
|
||||||
|
oldStore, oldPrev, oldLast, oldCycle := alertsStore, alertsPrevState, alertsLastMetricSeq, AlertsCycleTransferStatsStore
|
||||||
|
alertsStore = map[uint64]map[uint64][]model.TimedAlertPoint{7: {1: nil}}
|
||||||
|
alertsPrevState = map[uint64]map[uint64]uint8{7: {}}
|
||||||
|
alertsLastMetricSeq = map[uint64]map[uint64]uint64{7: {}}
|
||||||
|
AlertsCycleTransferStatsStore = map[uint64]*model.CycleTransferStats{}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
alertsStore, alertsPrevState, alertsLastMetricSeq, AlertsCycleTransferStatsStore = oldStore, oldPrev, oldLast, oldCycle
|
||||||
|
})
|
||||||
|
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{ID: 7},
|
||||||
|
Rules: []*model.Rule{{Type: "cpu", Max: 80, Duration: 100, Cover: model.RuleCoverAll}},
|
||||||
|
}
|
||||||
|
server := &model.Server{Common: model.Common{ID: 1}}
|
||||||
|
model.InitServer(server)
|
||||||
|
lease := server.AttachStateStream(alertTestStateStream{})
|
||||||
|
fixedReportTime := time.Now()
|
||||||
|
if !lease.UpdateState(&model.HostState{CPU: 99}, fixedReportTime) {
|
||||||
|
t.Fatal("state update rejected")
|
||||||
|
}
|
||||||
|
checkStatusForServer(alert, server)
|
||||||
|
if got := len(alertsStore[7][1]); got != 1 {
|
||||||
|
t.Fatalf("first report produced %d samples, want 1", got)
|
||||||
|
}
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
checkStatusForServer(alert, server)
|
||||||
|
}
|
||||||
|
if got := len(alertsStore[7][1]); got != 1 {
|
||||||
|
t.Fatalf("same report was counted %d times, want 1", got)
|
||||||
|
}
|
||||||
|
if !lease.Clear() {
|
||||||
|
t.Fatal("stream clear rejected")
|
||||||
|
}
|
||||||
|
checkStatusForServer(alert, server)
|
||||||
|
if got := len(alertsStore[7][1]); got != 0 {
|
||||||
|
t.Fatalf("disconnect retained %d high samples, want 0", got)
|
||||||
|
}
|
||||||
|
alertsPrevState[7][1] = _RuleCheckFail // incident was active before disconnect
|
||||||
|
newLease := server.AttachStateStream(alertTestStateStream{})
|
||||||
|
// Distinct reports may share a clock timestamp on coarse-resolution hosts.
|
||||||
|
if !newLease.UpdateState(&model.HostState{CPU: 99}, fixedReportTime) {
|
||||||
|
t.Fatal("reconnect state update rejected")
|
||||||
|
}
|
||||||
|
checkStatusForServer(alert, server)
|
||||||
|
if got := len(alertsStore[7][1]); got != 1 || alertsStore[7][1][0].Values[0] {
|
||||||
|
t.Fatalf("reconnect window = %v, want one high sample", alertsStore[7][1])
|
||||||
|
}
|
||||||
|
if alertsPrevState[7][1] != _RuleCheckFail {
|
||||||
|
t.Fatal("incomplete warm-up falsely resolved active incident")
|
||||||
|
}
|
||||||
|
replacement := &model.Server{Common: model.Common{ID: 1}}
|
||||||
|
model.InitServer(replacement)
|
||||||
|
replacementLease := replacement.AttachStateStream(alertTestStateStream{})
|
||||||
|
if !replacementLease.UpdateState(&model.HostState{CPU: 99}, fixedReportTime) {
|
||||||
|
t.Fatal("replacement server state update rejected")
|
||||||
|
}
|
||||||
|
checkStatusForServer(alert, replacement)
|
||||||
|
if got := len(alertsStore[7][1]); got != 2 {
|
||||||
|
t.Fatalf("replacement holder reused a report identity; samples=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertNewIncidentNotMutedByEarlierInflightIncident(t *testing.T) {
|
||||||
|
oldCache, oldNotifications := Cache, NotificationShared
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 601}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 603}, Name: "alert-test"}, []uint64{n.ID})
|
||||||
|
NotificationShared = nc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancelAlertDeliveries(602)
|
||||||
|
NotificationShared, Cache = oldNotifications, oldCache
|
||||||
|
})
|
||||||
|
started := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
delivered := make(chan string, 3)
|
||||||
|
nc.sendForTest = func(_ *model.Notification, desc string, _ *model.Server) error {
|
||||||
|
if desc == "first incident" {
|
||||||
|
close(started)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
delivered <- desc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{ID: 602},
|
||||||
|
NotificationGroupID: 603,
|
||||||
|
TriggerMode: model.ModeOnetimeTrigger,
|
||||||
|
}
|
||||||
|
server := &model.Server{Common: model.Common{ID: 604}}
|
||||||
|
label := NotificationMuteLabel.ServerIncident(server.ID, alert.ID)
|
||||||
|
startAlertDelivery(alert, server, "first incident", label, alertDeliveryIncident)
|
||||||
|
<-started
|
||||||
|
startAlertDelivery(alert, server, "recovered", NotificationMuteLabel.ServerIncidentResolved(server.ID, alert.ID), alertDeliveryRecovery)
|
||||||
|
startAlertDelivery(alert, server, "second incident", label, alertDeliveryIncident)
|
||||||
|
close(release)
|
||||||
|
timer := time.NewTimer(time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for len(seen) < 2 {
|
||||||
|
select {
|
||||||
|
case message := <-delivered:
|
||||||
|
seen[message] = true
|
||||||
|
case <-timer.C:
|
||||||
|
t.Fatalf("new incident was suppressed after old send completed: %v", seen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !seen["first incident"] || !seen["second incident"] || seen["recovered"] {
|
||||||
|
t.Fatalf("unexpected transition deliveries: %v", seen)
|
||||||
|
}
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
done := alertDeliveries[alert.ID][server.ID].done
|
||||||
|
alertDeliveryMu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("new incident delivery did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertRecoveryDeliveryStopsAfterSuccessInAlwaysMode(t *testing.T) {
|
||||||
|
oldCache, oldNotifications := Cache, NotificationShared
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 611}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 613}, Name: "recovery-test"}, []uint64{n.ID})
|
||||||
|
NotificationShared = nc
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cancelAlertDeliveries(612)
|
||||||
|
NotificationShared, Cache = oldNotifications, oldCache
|
||||||
|
})
|
||||||
|
|
||||||
|
delivered := make(chan struct{}, 2)
|
||||||
|
nc.sendForTest = func(*model.Notification, string, *model.Server) error {
|
||||||
|
delivered <- struct{}{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{ID: 612},
|
||||||
|
NotificationGroupID: 613,
|
||||||
|
TriggerMode: model.ModeAlwaysTrigger,
|
||||||
|
}
|
||||||
|
server := &model.Server{Common: model.Common{ID: 614}}
|
||||||
|
startAlertDelivery(alert, server, "recovered", "recovery-event", alertDeliveryRecovery)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-delivered:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("recovery notification was not delivered")
|
||||||
|
}
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
done := alertDeliveries[alert.ID][server.ID].done
|
||||||
|
alertDeliveryMu.Unlock()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("successful recovery delivery kept repeating in always mode")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-delivered:
|
||||||
|
t.Fatal("recovery notification was delivered more than once")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerDeleteCancelsAlertDelivery(t *testing.T) {
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
oldDeliveries := alertDeliveries
|
||||||
|
cancel := make(chan struct{})
|
||||||
|
alertDeliveries = map[uint64]map[uint64]*alertDeliveryEntry{
|
||||||
|
621: {622: {cancel: cancel, done: make(chan struct{})}},
|
||||||
|
}
|
||||||
|
alertDeliveryMu.Unlock()
|
||||||
|
t.Cleanup(func() {
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
alertDeliveries = oldDeliveries
|
||||||
|
alertDeliveryMu.Unlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
servers := &ServerClass{
|
||||||
|
class: class[uint64, *model.Server]{
|
||||||
|
list: map[uint64]*model.Server{
|
||||||
|
622: {Common: model.Common{ID: 622}, UUID: "delete-alert-worker"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
uuidToID: map[string]uint64{"delete-alert-worker": 622},
|
||||||
|
}
|
||||||
|
servers.Delete([]uint64{622})
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-cancel:
|
||||||
|
default:
|
||||||
|
t.Fatal("server deletion did not cancel its alert delivery")
|
||||||
|
}
|
||||||
|
alertDeliveryMu.Lock()
|
||||||
|
_, retained := alertDeliveries[621]
|
||||||
|
alertDeliveryMu.Unlock()
|
||||||
|
if retained {
|
||||||
|
t.Fatal("server deletion retained an empty alert delivery map")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlwaysAlertContinuesFailureTriggerTasks(t *testing.T) {
|
||||||
|
if !shouldRunAlertFailTasks(model.ModeAlwaysTrigger, false) {
|
||||||
|
t.Fatal("always mode stopped failure trigger tasks after the initial incident")
|
||||||
|
}
|
||||||
|
if shouldRunAlertFailTasks(model.ModeOnetimeTrigger, false) {
|
||||||
|
t.Fatal("one-time mode repeated failure trigger tasks")
|
||||||
|
}
|
||||||
|
if !shouldRunAlertFailTasks(model.ModeOnetimeTrigger, true) {
|
||||||
|
t.Fatal("one-time mode skipped failure trigger tasks for a new incident")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertDeliveryRetriesFailureAndStopsAfterSuccess(t *testing.T) {
|
||||||
|
cancel := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
attempts := 0
|
||||||
|
go func() {
|
||||||
|
runAlertDelivery(cancel, func() bool {
|
||||||
|
attempts++
|
||||||
|
return attempts == 2
|
||||||
|
}, false, time.Millisecond)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
close(cancel)
|
||||||
|
t.Fatal("delivery did not retry and finish")
|
||||||
|
}
|
||||||
|
if attempts != 2 {
|
||||||
|
t.Fatalf("got %d attempts, want 2", attempts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAlertDeliveryCancelsAfterRecovery(t *testing.T) {
|
||||||
|
cancel := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
started := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
runAlertDelivery(cancel, func() bool {
|
||||||
|
close(started)
|
||||||
|
return true
|
||||||
|
}, true, time.Millisecond)
|
||||||
|
close(done)
|
||||||
|
}()
|
||||||
|
<-started
|
||||||
|
close(cancel)
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("recovered incident delivery was not cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -117,3 +117,53 @@ func TestCheckStatus_SampleMemoryBounded(t *testing.T) {
|
|||||||
t.Fatalf("sample capacity grew unbounded: peak cap %d exceeds 4x window %d", peakCap, duration*4)
|
t.Fatalf("sample capacity grew unbounded: peak cap %d exceeds 4x window %d", peakCap, duration*4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckStatusForServerRecoversUnexpectedEvaluatorPanic(t *testing.T) {
|
||||||
|
originalStore := alertsStore
|
||||||
|
originalPrevState := alertsPrevState
|
||||||
|
originalCycleStore := AlertsCycleTransferStatsStore
|
||||||
|
alertsStore = map[uint64]map[uint64][]model.TimedAlertPoint{}
|
||||||
|
alertsPrevState = map[uint64]map[uint64]uint8{}
|
||||||
|
AlertsCycleTransferStatsStore = map[uint64]*model.CycleTransferStats{}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
alertsStore = originalStore
|
||||||
|
alertsPrevState = originalPrevState
|
||||||
|
AlertsCycleTransferStatsStore = originalCycleStore
|
||||||
|
})
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{ID: 77, UserID: 2},
|
||||||
|
Enable: &enabled,
|
||||||
|
Rules: []*model.Rule{{Type: "cpu", Duration: 3, Cover: model.RuleCoverAll}},
|
||||||
|
}
|
||||||
|
server := &model.Server{Common: model.Common{ID: 1, UserID: 2}}
|
||||||
|
model.InitServer(server)
|
||||||
|
|
||||||
|
// The deliberately missing alertsStore[alert.ID] map panics at assignment
|
||||||
|
// after evaluation. The per-alert/server recovery boundary must contain it.
|
||||||
|
checkStatusForServer(alert, server)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddCycleTransferStatsSkipsPoisonedPersistedRule(t *testing.T) {
|
||||||
|
original := AlertsCycleTransferStatsStore
|
||||||
|
AlertsCycleTransferStatsStore = map[uint64]*model.CycleTransferStats{}
|
||||||
|
t.Cleanup(func() { AlertsCycleTransferStatsStore = original })
|
||||||
|
|
||||||
|
enabled := true
|
||||||
|
alert := &model.AlertRule{
|
||||||
|
Common: model.Common{ID: 88},
|
||||||
|
Enable: &enabled,
|
||||||
|
Rules: []*model.Rule{{
|
||||||
|
Type: "transfer_in_cycle",
|
||||||
|
CycleInterval: 1,
|
||||||
|
Cover: model.RuleCoverAll,
|
||||||
|
// CycleStart intentionally nil, as it may be in a poisoned legacy DB row.
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
addCycleTransferStatsInfo(alert)
|
||||||
|
if _, exists := AlertsCycleTransferStatsStore[alert.ID]; exists {
|
||||||
|
t.Fatal("invalid persisted cycle rule must not be scheduled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/nezhahq/nezha/model"
|
"github.com/nezhahq/nezha/model"
|
||||||
"github.com/nezhahq/nezha/pkg/utils"
|
"github.com/nezhahq/nezha/pkg/utils"
|
||||||
@@ -14,6 +15,8 @@ var Conf *ConfigClass
|
|||||||
type ConfigClass struct {
|
type ConfigClass struct {
|
||||||
*model.Config
|
*model.Config
|
||||||
|
|
||||||
|
onlineHistoryRetentionMu sync.RWMutex
|
||||||
|
|
||||||
IgnoredIPNotificationServerIDs map[uint64]bool `json:"ignored_ip_notification_server_ids,omitempty"`
|
IgnoredIPNotificationServerIDs map[uint64]bool `json:"ignored_ip_notification_server_ids,omitempty"`
|
||||||
Oauth2Providers []string `json:"oauth2_providers,omitempty"`
|
Oauth2Providers []string `json:"oauth2_providers,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -59,3 +62,39 @@ func (c *ConfigClass) updateIgnoredIPNotificationID() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnlineHistoryRetentionDays returns the retention policy visible to queries and pruning.
|
||||||
|
func (c *ConfigClass) OnlineHistoryRetentionDays() int {
|
||||||
|
c.onlineHistoryRetentionMu.RLock()
|
||||||
|
defer c.onlineHistoryRetentionMu.RUnlock()
|
||||||
|
return c.OnlineStatsDays
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommitOnlineHistoryRetention applies a retention change with the settings save.
|
||||||
|
func (c *ConfigClass) CommitOnlineHistoryRetention(days *int, persist func() error) error {
|
||||||
|
// Readers must not capture a shorter cutoff while Save can still fail.
|
||||||
|
// Serialize assignment, persistence and rollback with the policy snapshot.
|
||||||
|
c.onlineHistoryRetentionMu.Lock()
|
||||||
|
defer c.onlineHistoryRetentionMu.Unlock()
|
||||||
|
previous := c.OnlineStatsDays
|
||||||
|
if days != nil {
|
||||||
|
c.OnlineStatsDays = *days
|
||||||
|
}
|
||||||
|
if err := persist(); err != nil {
|
||||||
|
c.OnlineStatsDays = previous
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SettingsSnapshot copies response fields without copying the retention mutex.
|
||||||
|
func (c *ConfigClass) SettingsSnapshot() model.Setting {
|
||||||
|
c.onlineHistoryRetentionMu.RLock()
|
||||||
|
defer c.onlineHistoryRetentionMu.RUnlock()
|
||||||
|
return model.Setting{
|
||||||
|
ConfigForGuests: c.ConfigForGuests,
|
||||||
|
ConfigDashboard: c.ConfigDashboard,
|
||||||
|
IgnoredIPNotificationServerIDs: c.IgnoredIPNotificationServerIDs,
|
||||||
|
Oauth2Providers: c.Oauth2Providers,
|
||||||
|
}
|
||||||
|
}
|
||||||
+203
-71
@@ -22,7 +22,6 @@ import (
|
|||||||
whoisparser "github.com/likexian/whois-parser"
|
whoisparser "github.com/likexian/whois-parser"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
// SyncDomainPrice 从 哪煮米(nazhumi.com) 获取域名续费价格
|
// SyncDomainPrice 从 哪煮米(nazhumi.com) 获取域名续费价格
|
||||||
func SyncDomainPrice(billing *model.BillingDataMod, domainName string) {
|
func SyncDomainPrice(billing *model.BillingDataMod, domainName string) {
|
||||||
// 获取 TLD
|
// 获取 TLD
|
||||||
@@ -350,7 +349,7 @@ func isDomainNotificationDay(daysLeft int) bool {
|
|||||||
parts := strings.Split(daysStr, ",")
|
parts := strings.Split(daysStr, ",")
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
d, err := strconv.Atoi(strings.TrimSpace(p))
|
d, err := strconv.Atoi(strings.TrimSpace(p))
|
||||||
if err == nil && d == daysLeft+1 {
|
if err == nil && (d == daysLeft+1 || (d == 0 && daysLeft == 0)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -365,18 +364,118 @@ func isServerNotificationDay(daysLeft int) bool {
|
|||||||
parts := strings.Split(daysStr, ",")
|
parts := strings.Split(daysStr, ",")
|
||||||
for _, p := range parts {
|
for _, p := range parts {
|
||||||
d, err := strconv.Atoi(strings.TrimSpace(p))
|
d, err := strconv.Atoi(strings.TrimSpace(p))
|
||||||
if err == nil && d == daysLeft+1 {
|
if err == nil && (d == daysLeft+1 || (d == 0 && daysLeft == 0)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isAutoRenewal(v any) bool {
|
||||||
|
if v == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case string:
|
||||||
|
s := strings.ToLower(strings.TrimSpace(val))
|
||||||
|
return s == "1" || s == "true" || s == "auto" || s == "yes" || s == "on"
|
||||||
|
case bool:
|
||||||
|
return val
|
||||||
|
case float64:
|
||||||
|
return val == 1
|
||||||
|
case int:
|
||||||
|
return val == 1
|
||||||
|
case int64:
|
||||||
|
return val == 1
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func advanceRenewalDate(startDateStr, endDateStr, cycle string, now time.Time) (newStartStr, newEndStr string, newEndDate time.Time, renewed bool) {
|
||||||
|
endDate, err := ParseFlexibleDate(endDateStr)
|
||||||
|
if err != nil || !now.After(endDate) {
|
||||||
|
return startDateStr, endDateStr, endDate, false
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate, _ := ParseFlexibleDate(startDateStr)
|
||||||
|
|
||||||
|
cycleLower := strings.ToLower(strings.TrimSpace(cycle))
|
||||||
|
var years, months, days int
|
||||||
|
switch cycleLower {
|
||||||
|
case "day", "天", "日", "1day", "1天", "1日":
|
||||||
|
days = 1
|
||||||
|
case "week", "周", "星期", "1week", "1周":
|
||||||
|
days = 7
|
||||||
|
case "month", "月", "1month", "1月", "按月":
|
||||||
|
months = 1
|
||||||
|
case "quarter", "季", "季度", "3month", "3月", "按季":
|
||||||
|
months = 3
|
||||||
|
case "halfyear", "半年", "6month", "6月", "半年度":
|
||||||
|
months = 6
|
||||||
|
case "year", "年", "1year", "1年", "按年", "每年":
|
||||||
|
years = 1
|
||||||
|
case "2year", "2年", "两年":
|
||||||
|
years = 2
|
||||||
|
case "3year", "3年", "三年":
|
||||||
|
years = 3
|
||||||
|
case "5year", "5年", "五年":
|
||||||
|
years = 5
|
||||||
|
default:
|
||||||
|
if !startDate.IsZero() && endDate.After(startDate) {
|
||||||
|
duration := endDate.Sub(startDate)
|
||||||
|
curEnd := endDate
|
||||||
|
curStart := startDate
|
||||||
|
for !curEnd.After(now) {
|
||||||
|
curStart = curEnd
|
||||||
|
curEnd = curEnd.Add(duration)
|
||||||
|
}
|
||||||
|
newEndDate = curEnd
|
||||||
|
return formatLikeOriginal(startDateStr, curStart), formatLikeOriginal(endDateStr, curEnd), newEndDate, true
|
||||||
|
}
|
||||||
|
years = 1 // 默认按年
|
||||||
|
}
|
||||||
|
|
||||||
|
curEnd := endDate
|
||||||
|
curStart := startDate
|
||||||
|
for !curEnd.After(now) {
|
||||||
|
curStart = curEnd
|
||||||
|
curEnd = curEnd.AddDate(years, months, days)
|
||||||
|
}
|
||||||
|
|
||||||
|
newEndDate = curEnd
|
||||||
|
newStartStr = formatLikeOriginal(startDateStr, curStart)
|
||||||
|
newEndStr = formatLikeOriginal(endDateStr, curEnd)
|
||||||
|
return newStartStr, newEndStr, newEndDate, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLikeOriginal(original string, t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(original) == 10 && !strings.Contains(original, "T") {
|
||||||
|
return t.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
if len(original) == 19 && strings.Contains(original, " ") {
|
||||||
|
return t.Format("2006-01-02 15:04:05")
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFlexibleDate 解析多种日期格式 (YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, RFC3339)
|
||||||
|
func ParseFlexibleDate(dateStr string) (time.Time, error) {
|
||||||
|
if len(dateStr) == 10 { // YYYY-MM-DD
|
||||||
|
return time.Parse("2006-01-02", dateStr)
|
||||||
|
} else if len(dateStr) == 19 && dateStr[10] == ' ' { // YYYY-MM-DD HH:MM:SS
|
||||||
|
return time.Parse("2006-01-02 15:04:05", dateStr)
|
||||||
|
}
|
||||||
|
return time.Parse(time.RFC3339, dateStr)
|
||||||
|
}
|
||||||
|
|
||||||
// CronJobForDomainStatus 检查域名到期和自动续费的定时任务
|
// CronJobForDomainStatus 检查域名到期和自动续费的定时任务
|
||||||
func CronJobForDomainStatus() {
|
func CronJobForDomainStatus() {
|
||||||
log.Println("NEZHA>> Cron::开始执行域名状态检查任务")
|
log.Println("NEZHA>> Cron::开始执行域名状态检查任务")
|
||||||
var domains []model.Domain
|
var domains []model.Domain
|
||||||
if err := DB.Where("status = ?", "verified").Find(&domains).Error; err != nil {
|
if err := DB.Find(&domains).Error; err != nil {
|
||||||
log.Printf("NEZHA>> Cron::Error fetching domains: %v", err)
|
log.Printf("NEZHA>> Cron::Error fetching domains: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -399,72 +498,57 @@ func CronJobForDomainStatus() {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理类似 2026-10-10 甚至其他不带时区的格式
|
endDate, err := ParseFlexibleDate(billing.EndDate)
|
||||||
endDateStr := billing.EndDate
|
|
||||||
var endDate time.Time
|
|
||||||
var err error
|
|
||||||
if len(endDateStr) == 10 { // YYYY-MM-DD
|
|
||||||
endDate, err = time.Parse("2006-01-02", endDateStr)
|
|
||||||
} else if len(endDateStr) == 19 && endDateStr[10] == ' ' { // YYYY-MM-DD HH:MM:SS
|
|
||||||
endDate, err = time.Parse("2006-01-02 15:04:05", endDateStr)
|
|
||||||
} else {
|
|
||||||
endDate, err = time.Parse(time.RFC3339, endDateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("NEZHA>> Cron::Error parsing end date for domain %s: %v", d.Domain, err)
|
log.Printf("NEZHA>> Cron::Error parsing end date for domain %s: %v", d.Domain, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
if isAutoRenewal(billing.AutoRenewal) && now.After(endDate) {
|
||||||
|
newStartStr, newEndStr, newEnd, renewed := advanceRenewalDate(billing.RegisteredDate, billing.EndDate, billing.Cycle, now)
|
||||||
// 只有在到期前一定天数通知,且避开重复通知 (简单逻辑:每天通知一次)
|
if renewed {
|
||||||
if Conf.ExpiryNotificationGroupID != 0 && isDomainNotificationDay(daysLeft) {
|
billing.EndDate = newEndStr
|
||||||
msg := ""
|
if billing.RegisteredDate != "" {
|
||||||
if daysLeft+1 > 0 {
|
billing.RegisteredDate = newStartStr
|
||||||
msg = fmt.Sprintf("域名 [%s] 即将到期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02"))
|
|
||||||
} else {
|
|
||||||
msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02"))
|
|
||||||
}
|
}
|
||||||
NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft))
|
|
||||||
}
|
|
||||||
|
|
||||||
if now.After(endDate) {
|
|
||||||
if billing.AutoRenewal == "1" {
|
|
||||||
var newEndDate time.Time
|
|
||||||
renewalYears := 0
|
|
||||||
renewalMonths := 0
|
|
||||||
switch billing.Cycle {
|
|
||||||
case "年":
|
|
||||||
renewalYears = 1
|
|
||||||
case "月":
|
|
||||||
renewalMonths = 1
|
|
||||||
default:
|
|
||||||
log.Printf("NEZHA>> Cron::未知续费周期 '%s' for domain %s", billing.Cycle, d.Domain)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
newEndDate = endDate.AddDate(renewalYears, renewalMonths, 0)
|
|
||||||
billing.EndDate = newEndDate.Format(time.RFC3339)
|
|
||||||
newBillingData, _ := json.Marshal(billing)
|
newBillingData, _ := json.Marshal(billing)
|
||||||
d.BillingData = newBillingData
|
d.BillingData = newBillingData
|
||||||
log.Printf("NEZHA>> Cron::域名 %s 已自动续费至 %s", d.Domain, billing.EndDate)
|
endDate = newEnd
|
||||||
|
log.Printf("NEZHA>> Cron::域名 %s 开启了自动续费,已自动顺延至 %s", d.Domain, billing.EndDate)
|
||||||
if err := DB.Save(&d).Error; err != nil {
|
if err := DB.Save(&d).Error; err != nil {
|
||||||
log.Printf("NEZHA>> Cron::Error saving auto-renewed domain %s: %v", d.Domain, err)
|
log.Printf("NEZHA>> Cron::Error saving auto-renewed domain %s: %v", d.Domain, err)
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
|
} else if now.After(endDate) {
|
||||||
d.Status = "expired"
|
d.Status = "expired"
|
||||||
log.Printf("NEZHA>> Cron::域名 %s 已过期", d.Domain)
|
log.Printf("NEZHA>> Cron::域名 %s 已过期", d.Domain)
|
||||||
if err := DB.Save(&d).Error; err != nil {
|
if err := DB.Save(&d).Error; err != nil {
|
||||||
log.Printf("NEZHA>> Cron::Error marking domain %s as expired: %v", d.Domain, err)
|
log.Printf("NEZHA>> Cron::Error marking domain %s as expired: %v", d.Domain, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
||||||
|
|
||||||
|
if isDomainNotificationDay(daysLeft) {
|
||||||
|
msg := ""
|
||||||
|
if daysLeft+1 > 0 {
|
||||||
|
msg = fmt.Sprintf("域名 [%s] 即将到期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02"))
|
||||||
|
} else {
|
||||||
|
msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02"))
|
||||||
|
}
|
||||||
|
if Conf.ExpiryNotificationGroupID != 0 {
|
||||||
|
NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft))
|
||||||
|
}
|
||||||
|
SendTGAdminNotification("🌐 <b>域名到期提醒</b>\n\n" + msg)
|
||||||
|
if model.SendGlobalEmailFunc != nil {
|
||||||
|
_ = model.SendGlobalEmailFunc(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Println("NEZHA>> Cron::域名状态检查任务执行完毕")
|
log.Println("NEZHA>> Cron::域名状态检查任务执行完毕")
|
||||||
}
|
}
|
||||||
|
|
||||||
// CronJobForServerStatus 检查服务器/VPS 到期通知
|
// CronJobForServerStatus 检查服务器/VPS 到期通知与自动续费滚动
|
||||||
func CronJobForServerStatus() {
|
func CronJobForServerStatus() {
|
||||||
log.Println("NEZHA>> Cron::开始执行服务器到期检查任务")
|
log.Println("NEZHA>> Cron::开始执行服务器到期检查任务")
|
||||||
var servers []model.Server
|
var servers []model.Server
|
||||||
@@ -476,51 +560,99 @@ func CronJobForServerStatus() {
|
|||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
for i := range servers {
|
for i := range servers {
|
||||||
s := servers[i]
|
s := &servers[i]
|
||||||
var pn struct {
|
|
||||||
BillingDataMod struct {
|
var publicNoteObj map[string]any
|
||||||
EndDate string `json:"endDate"`
|
var noteObj map[string]any
|
||||||
} `json:"billingDataMod"`
|
var billingMap map[string]any
|
||||||
}
|
isPublicNote := false
|
||||||
|
isPrivateNote := false
|
||||||
|
|
||||||
if s.PublicNote != "" {
|
if s.PublicNote != "" {
|
||||||
_ = json.Unmarshal([]byte(s.PublicNote), &pn)
|
if err := json.Unmarshal([]byte(s.PublicNote), &publicNoteObj); err == nil && publicNoteObj != nil {
|
||||||
|
if bm, ok := publicNoteObj["billingDataMod"].(map[string]any); ok && bm != nil {
|
||||||
|
billingMap = bm
|
||||||
|
isPublicNote = true
|
||||||
}
|
}
|
||||||
if pn.BillingDataMod.EndDate == "" {
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if billingMap == nil && s.Note != "" {
|
||||||
|
if err := json.Unmarshal([]byte(s.Note), ¬eObj); err == nil && noteObj != nil {
|
||||||
|
if bm, ok := noteObj["billingDataMod"].(map[string]any); ok && bm != nil {
|
||||||
|
billingMap = bm
|
||||||
|
isPrivateNote = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if billingMap == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 忽略前端默认生成的空日期
|
endDateStr, _ := billingMap["endDate"].(string)
|
||||||
if strings.HasPrefix(pn.BillingDataMod.EndDate, "0000-00-00") {
|
if endDateStr == "" || strings.HasPrefix(endDateStr, "0000-00-00") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理类似 2026-10-10 甚至其他不带时区的格式
|
startDateStr, _ := billingMap["startDate"].(string)
|
||||||
endDateStr := pn.BillingDataMod.EndDate
|
cycle, _ := billingMap["cycle"].(string)
|
||||||
var endDate time.Time
|
autoRenewalVal := billingMap["autoRenewal"]
|
||||||
var err error
|
|
||||||
if len(endDateStr) == 10 { // YYYY-MM-DD
|
|
||||||
endDate, err = time.Parse("2006-01-02", endDateStr)
|
|
||||||
} else if len(endDateStr) == 19 && endDateStr[10] == ' ' { // YYYY-MM-DD HH:MM:SS
|
|
||||||
endDate, err = time.Parse("2006-01-02 15:04:05", endDateStr)
|
|
||||||
} else {
|
|
||||||
endDate, err = time.Parse(time.RFC3339, endDateStr)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
endDate, err := ParseFlexibleDate(endDateStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("NEZHA>> Cron::Error parsing end date for VPS %s: %v", s.Name, err)
|
log.Printf("NEZHA>> Cron::Error parsing end date for VPS %s: %v", s.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果开启了自动续费且已到达或超过到期时间,自动将周期顺延至未来的有效周期
|
||||||
|
if isAutoRenewal(autoRenewalVal) && now.After(endDate) {
|
||||||
|
newStartStr, newEndStr, newEnd, renewed := advanceRenewalDate(startDateStr, endDateStr, cycle, now)
|
||||||
|
if renewed {
|
||||||
|
billingMap["endDate"] = newEndStr
|
||||||
|
if startDateStr != "" {
|
||||||
|
billingMap["startDate"] = newStartStr
|
||||||
|
}
|
||||||
|
endDate = newEnd
|
||||||
|
|
||||||
|
if isPublicNote {
|
||||||
|
publicNoteObj["billingDataMod"] = billingMap
|
||||||
|
if updatedJSON, err := json.Marshal(publicNoteObj); err == nil {
|
||||||
|
s.PublicNote = string(updatedJSON)
|
||||||
|
}
|
||||||
|
} else if isPrivateNote {
|
||||||
|
noteObj["billingDataMod"] = billingMap
|
||||||
|
if updatedJSON, err := json.Marshal(noteObj); err == nil {
|
||||||
|
s.Note = string(updatedJSON)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := DB.Save(s).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Cron::Error saving auto-renewed VPS %s: %v", s.Name, err)
|
||||||
|
} else {
|
||||||
|
ServerShared.Update(s, s.UUID)
|
||||||
|
log.Printf("NEZHA>> Cron::VPS [%s] 开启了自动续费,到期时间已自动顺延至 %s (周期: %s)", s.Name, newEndStr, cycle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
daysLeft := int(endDate.Sub(now).Hours() / 24)
|
||||||
|
|
||||||
if Conf.ExpiryNotificationGroupID != 0 && isServerNotificationDay(daysLeft) {
|
if isServerNotificationDay(daysLeft) {
|
||||||
msg := ""
|
msg := ""
|
||||||
if daysLeft+1 > 0 {
|
if daysLeft+1 > 0 {
|
||||||
msg = fmt.Sprintf("VPS [%s] 即将到期,剩余 %d 天。到期时间: %s", s.Name, daysLeft+1, endDate.Format("2006-01-02"))
|
msg = fmt.Sprintf("VPS [%s] 即将到期,剩余 %d 天。到期时间: %s", s.Name, daysLeft+1, endDate.Format("2006-01-02"))
|
||||||
} else {
|
} else {
|
||||||
msg = fmt.Sprintf("VPS [%s] 已到期!到期时间: %s", s.Name, endDate.Format("2006-01-02"))
|
msg = fmt.Sprintf("VPS [%s] 已到期!到期时间: %s", s.Name, endDate.Format("2006-01-02"))
|
||||||
}
|
}
|
||||||
NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft), &s)
|
if Conf.ExpiryNotificationGroupID != 0 {
|
||||||
|
NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft), s)
|
||||||
|
}
|
||||||
|
|
||||||
|
SendTGAdminNotification("🖥 <b>VPS 到期提醒</b>\n\n" + msg)
|
||||||
|
if model.SendGlobalEmailFunc != nil {
|
||||||
|
_ = model.SendGlobalEmailFunc(msg)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕")
|
log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕")
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package singleton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAdvanceRenewalDate(t *testing.T) {
|
||||||
|
now, _ := time.Parse("2006-01-02", "2026-08-31")
|
||||||
|
|
||||||
|
// Case 1: 1 Year cycle, expired in 2026-07-11
|
||||||
|
startStr, endStr, newEnd, renewed := advanceRenewalDate("2025-07-11", "2026-07-11", "Year", now)
|
||||||
|
require.True(t, renewed)
|
||||||
|
require.Equal(t, "2026-07-11", startStr)
|
||||||
|
require.Equal(t, "2027-07-11", endStr)
|
||||||
|
require.True(t, newEnd.After(now))
|
||||||
|
|
||||||
|
// Case 2: 1 Month cycle, expired 3 months ago (2026-05-11)
|
||||||
|
startStr, endStr, newEnd, renewed = advanceRenewalDate("2026-04-11", "2026-05-11", "Month", now)
|
||||||
|
require.True(t, renewed)
|
||||||
|
require.Equal(t, "2026-09-11", endStr)
|
||||||
|
require.True(t, newEnd.After(now))
|
||||||
|
|
||||||
|
// Case 3: RFC3339 format preserved
|
||||||
|
startStr, endStr, newEnd, renewed = advanceRenewalDate("2025-07-11T00:00:00Z", "2026-07-11T00:00:00Z", "年", now)
|
||||||
|
require.True(t, renewed)
|
||||||
|
require.Contains(t, endStr, "2027-07-11")
|
||||||
|
require.True(t, newEnd.After(now))
|
||||||
|
|
||||||
|
// Case 4: Future date - should not renew
|
||||||
|
_, _, _, renewed = advanceRenewalDate("2026-08-01", "2026-10-01", "Month", now)
|
||||||
|
require.False(t, renewed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAutoRenewal(t *testing.T) {
|
||||||
|
require.True(t, isAutoRenewal("1"))
|
||||||
|
require.True(t, isAutoRenewal("true"))
|
||||||
|
require.True(t, isAutoRenewal("auto"))
|
||||||
|
require.True(t, isAutoRenewal("yes"))
|
||||||
|
require.True(t, isAutoRenewal(true))
|
||||||
|
require.True(t, isAutoRenewal(1))
|
||||||
|
require.True(t, isAutoRenewal(float64(1)))
|
||||||
|
|
||||||
|
require.False(t, isAutoRenewal("0"))
|
||||||
|
require.False(t, isAutoRenewal("false"))
|
||||||
|
require.False(t, isAutoRenewal(false))
|
||||||
|
require.False(t, isAutoRenewal(0))
|
||||||
|
require.False(t, isAutoRenewal(nil))
|
||||||
|
}
|
||||||
@@ -2,24 +2,20 @@
|
|||||||
name: "OfficialAdmin"
|
name: "OfficialAdmin"
|
||||||
repository: "https://github.com/nezhahq/admin-frontend"
|
repository: "https://github.com/nezhahq/admin-frontend"
|
||||||
author: "nezhahq"
|
author: "nezhahq"
|
||||||
version: "v2.3.4"
|
version: "v2.4.0"
|
||||||
is_admin: true
|
is_admin: true
|
||||||
is_official: true
|
is_official: true
|
||||||
- path: "user-dist"
|
- path: "user-dist"
|
||||||
name: "Official"
|
name: "Official"
|
||||||
repository: "https://github.com/hamster1963/nezha-dash-v2"
|
repository: "https://github.com/hamster1963/nezha-dash-v2"
|
||||||
author: "hamster1963"
|
author: "hamster1963"
|
||||||
version: "v2.4.2"
|
version: "v2.5.1"
|
||||||
is_official: true
|
is_official: true
|
||||||
- path: "nezha-pixel-dist"
|
- path: "nezha-pixel-dist"
|
||||||
name: "Nezha-Pixel"
|
name: "Nezha-Pixel"
|
||||||
repository: "https://github.com/karllao/nezha-pixel"
|
repository: "https://github.com/karllao/nezha-pixel"
|
||||||
author: "karllao"
|
author: "karllao"
|
||||||
version: "v1.6.0"
|
version: "v1.6.0"
|
||||||
# Third-party user themes consume the opaque Server.PublicNote field. Theme
|
|
||||||
# maintainers must validate URL schemes (including after decoding) before using
|
|
||||||
# values such as customData.orderLink in href or window.open; the Dashboard
|
|
||||||
# backend and admin frontend do not execute those fields.
|
|
||||||
- path: "nazhua-dist"
|
- path: "nazhua-dist"
|
||||||
name: "Nazhua"
|
name: "Nazhua"
|
||||||
repository: "https://github.com/hi2shark/nazhua"
|
repository: "https://github.com/hi2shark/nazhua"
|
||||||
@@ -29,7 +25,7 @@
|
|||||||
name: "Aobobo"
|
name: "Aobobo"
|
||||||
repository: "https://github.com/hi2shark/aobobo"
|
repository: "https://github.com/hi2shark/aobobo"
|
||||||
author: "hi2shark"
|
author: "hi2shark"
|
||||||
version: "v1.5.1"
|
version: "v1.5.4"
|
||||||
- path: "nezha-ascii-dist"
|
- path: "nezha-ascii-dist"
|
||||||
name: "Nezha-ASCII"
|
name: "Nezha-ASCII"
|
||||||
repository: "https://github.com/hamster1963/nezha-ascii"
|
repository: "https://github.com/hamster1963/nezha-ascii"
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ func init() {
|
|||||||
if err == nil && adminChatID != 0 {
|
if err == nil && adminChatID != 0 {
|
||||||
safeDesc := html.EscapeString(message)
|
safeDesc := html.EscapeString(message)
|
||||||
msg := fmt.Sprintf("⚠️ <b>Nezha 报警通知</b>\n\n%s", safeDesc)
|
msg := fmt.Sprintf("⚠️ <b>Nezha 报警通知</b>\n\n%s", safeDesc)
|
||||||
sendTGMessage(adminChatID, msg)
|
SendTGMessage(adminChatID, msg)
|
||||||
log.Printf("NEZHA>> Sent notification to Telegram Admin Bot")
|
log.Printf("NEZHA>> Sent notification to Telegram Admin Bot")
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -59,6 +60,226 @@ type NotificationClass struct {
|
|||||||
|
|
||||||
groupList map[uint64]string
|
groupList map[uint64]string
|
||||||
groupMu sync.RWMutex
|
groupMu sync.RWMutex
|
||||||
|
deliveryMu sync.Mutex
|
||||||
|
deliveryKeys map[string]*notificationKeyLock
|
||||||
|
asyncInFlight map[string]struct{}
|
||||||
|
asyncRetryAfter map[string]time.Time
|
||||||
|
asyncRetryDelay map[string]time.Duration
|
||||||
|
muteGeneration map[string]uint64
|
||||||
|
serviceDelivery map[uint64]*orderedServiceDelivery
|
||||||
|
sendForTest func(*model.Notification, string, *model.Server) error
|
||||||
|
}
|
||||||
|
|
||||||
|
type serviceDeliveryRequest struct {
|
||||||
|
groupID uint64
|
||||||
|
desc string
|
||||||
|
label string
|
||||||
|
}
|
||||||
|
|
||||||
|
type orderedServiceDelivery struct {
|
||||||
|
current serviceDeliveryRequest
|
||||||
|
pending *serviceDeliveryRequest
|
||||||
|
update chan struct{}
|
||||||
|
cancel chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendServiceState preserves incident→recovery order without allowing a
|
||||||
|
// slow webhook to accumulate one goroutine per probe result. Only the latest
|
||||||
|
// pending phase is retained; a failed current phase retries with backoff.
|
||||||
|
func (c *NotificationClass) SendServiceState(serviceID, groupID uint64, desc, label string) {
|
||||||
|
req := serviceDeliveryRequest{groupID: groupID, desc: desc, label: label}
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
if c.serviceDelivery == nil {
|
||||||
|
c.serviceDelivery = make(map[uint64]*orderedServiceDelivery)
|
||||||
|
}
|
||||||
|
state := c.serviceDelivery[serviceID]
|
||||||
|
if state == nil {
|
||||||
|
state = &orderedServiceDelivery{current: req, update: make(chan struct{}, 1), cancel: make(chan struct{}), done: make(chan struct{})}
|
||||||
|
c.serviceDelivery[serviceID] = state
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
go c.runServiceDelivery(serviceID, state)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.pending == nil && state.current.label == label {
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if state.pending == nil || state.pending.label != label {
|
||||||
|
state.pending = &req
|
||||||
|
select {
|
||||||
|
case state.update <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationClass) runServiceDelivery(serviceID uint64, state *orderedServiceDelivery) {
|
||||||
|
defer close(state.done)
|
||||||
|
delay := 30 * time.Second
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-state.cancel:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
delivered := c.SendNotification(state.current.groupID, state.current.desc, state.current.label)
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
// Do not advance to a later phase until every recipient has received the
|
||||||
|
// current one. Otherwise a failed incident can be discarded as soon as a
|
||||||
|
// recovery is queued, leaving that recipient with a recovery but no incident.
|
||||||
|
if delivered && state.pending != nil {
|
||||||
|
next := state.pending
|
||||||
|
state.current = *next
|
||||||
|
state.pending = nil
|
||||||
|
delay = 30 * time.Second
|
||||||
|
select {
|
||||||
|
case <-state.update:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if delivered {
|
||||||
|
if c.serviceDelivery[serviceID] == state {
|
||||||
|
delete(c.serviceDelivery, serviceID)
|
||||||
|
}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
timer := time.NewTimer(delay)
|
||||||
|
select {
|
||||||
|
case <-state.cancel:
|
||||||
|
timer.Stop()
|
||||||
|
return
|
||||||
|
case <-state.update:
|
||||||
|
timer.Stop()
|
||||||
|
case <-timer.C:
|
||||||
|
delay = min(delay*2, 15*time.Minute)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationClass) CancelServiceState(serviceID uint64) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
if state := c.serviceDelivery[serviceID]; state != nil {
|
||||||
|
close(state.cancel)
|
||||||
|
delete(c.serviceDelivery, serviceID)
|
||||||
|
}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNotificationAsync coalesces concurrent service-probe reports for the
|
||||||
|
// same event before allocating a webhook goroutine. Alert transitions use the
|
||||||
|
// synchronous SendNotification path so they retain their retry semantics.
|
||||||
|
func (c *NotificationClass) SendNotificationAsync(groupID uint64, desc, muteLabel string, ext ...*model.Server) {
|
||||||
|
c.sendNotificationAsync(groupID, desc, muteLabel, nil, ext...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationClass) sendNotificationAsync(groupID uint64, desc, muteLabel string, onSuccess func(), ext ...*model.Server) {
|
||||||
|
key := fmt.Sprintf("%d:%s", groupID, muteLabel)
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
if c.asyncInFlight == nil {
|
||||||
|
c.asyncInFlight = make(map[string]struct{})
|
||||||
|
}
|
||||||
|
if _, busy := c.asyncInFlight[key]; busy {
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if time.Now().Before(c.asyncRetryAfter[key]) {
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.asyncInFlight[key] = struct{}{}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
go func() {
|
||||||
|
delivered := c.SendNotification(groupID, desc, muteLabel, ext...)
|
||||||
|
if delivered && onSuccess != nil {
|
||||||
|
onSuccess()
|
||||||
|
}
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
if delivered {
|
||||||
|
delete(c.asyncRetryAfter, key)
|
||||||
|
delete(c.asyncRetryDelay, key)
|
||||||
|
} else {
|
||||||
|
if c.asyncRetryAfter == nil {
|
||||||
|
c.asyncRetryAfter = make(map[string]time.Time)
|
||||||
|
c.asyncRetryDelay = make(map[string]time.Duration)
|
||||||
|
}
|
||||||
|
delay := c.asyncRetryDelay[key]
|
||||||
|
if delay == 0 {
|
||||||
|
delay = 30 * time.Second
|
||||||
|
} else {
|
||||||
|
delay = min(delay*2, 15*time.Minute)
|
||||||
|
}
|
||||||
|
c.asyncRetryDelay[key] = delay
|
||||||
|
c.asyncRetryAfter[key] = time.Now().Add(delay)
|
||||||
|
c.pruneAsyncBackoffLocked()
|
||||||
|
}
|
||||||
|
delete(c.asyncInFlight, key)
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxAsyncBackoffKeys = 4096
|
||||||
|
|
||||||
|
// Bound memory when a failing webhook sees many short-lived event labels.
|
||||||
|
// Evicted old event keys are no longer active after their phase changes.
|
||||||
|
func (c *NotificationClass) pruneAsyncBackoffLocked() {
|
||||||
|
if len(c.asyncRetryAfter) <= maxAsyncBackoffKeys {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for key, until := range c.asyncRetryAfter {
|
||||||
|
if !now.Before(until) {
|
||||||
|
delete(c.asyncRetryAfter, key)
|
||||||
|
delete(c.asyncRetryDelay, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range c.asyncRetryAfter {
|
||||||
|
if len(c.asyncRetryAfter) <= maxAsyncBackoffKeys {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
delete(c.asyncRetryAfter, key)
|
||||||
|
delete(c.asyncRetryDelay, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type notificationKeyLock struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
refs int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *NotificationClass) lockDeliveryKey(key string) func() {
|
||||||
|
if key == "" {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
if c.deliveryKeys == nil {
|
||||||
|
c.deliveryKeys = make(map[string]*notificationKeyLock)
|
||||||
|
}
|
||||||
|
entry := c.deliveryKeys[key]
|
||||||
|
if entry == nil {
|
||||||
|
entry = new(notificationKeyLock)
|
||||||
|
c.deliveryKeys[key] = entry
|
||||||
|
}
|
||||||
|
entry.refs++
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
entry.mu.Lock()
|
||||||
|
return func() {
|
||||||
|
entry.mu.Unlock()
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
entry.refs--
|
||||||
|
if entry.refs == 0 {
|
||||||
|
delete(c.deliveryKeys, key)
|
||||||
|
}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNotificationClass() *NotificationClass {
|
func NewNotificationClass() *NotificationClass {
|
||||||
@@ -116,20 +337,29 @@ func NewNotificationClass() *NotificationClass {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *NotificationClass) Update(n *model.Notification) {
|
func (c *NotificationClass) Update(n *model.Notification) {
|
||||||
|
func() {
|
||||||
c.listMu.Lock()
|
c.listMu.Lock()
|
||||||
|
defer c.listMu.Unlock()
|
||||||
|
|
||||||
_, ok := c.list[n.ID]
|
_, ok := c.list[n.ID]
|
||||||
c.list[n.ID] = n
|
c.list[n.ID] = n
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if ok {
|
gids := c.idToGroupList[n.ID]
|
||||||
if gids, ok := c.idToGroupList[n.ID]; ok {
|
|
||||||
for gid := range gids {
|
for gid := range gids {
|
||||||
c.groupToIDList[gid][n.ID] = n
|
group, exists := c.groupToIDList[gid]
|
||||||
|
if !exists {
|
||||||
|
delete(gids, gid)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
group[n.ID] = n
|
||||||
}
|
}
|
||||||
|
if len(gids) == 0 {
|
||||||
|
delete(c.idToGroupList, n.ID)
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
c.listMu.Unlock()
|
|
||||||
c.sortList()
|
c.sortList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,29 +367,19 @@ func (c *NotificationClass) UpdateGroup(ng *model.NotificationGroup, ngn []uint6
|
|||||||
c.groupMu.Lock()
|
c.groupMu.Lock()
|
||||||
defer c.groupMu.Unlock()
|
defer c.groupMu.Unlock()
|
||||||
|
|
||||||
_, ok := c.groupList[ng.ID]
|
|
||||||
c.groupList[ng.ID] = ng.Name
|
c.groupList[ng.ID] = ng.Name
|
||||||
|
|
||||||
c.listMu.Lock()
|
c.listMu.Lock()
|
||||||
defer c.listMu.Unlock()
|
defer c.listMu.Unlock()
|
||||||
if !ok {
|
|
||||||
c.groupToIDList[ng.ID] = make(map[uint64]*model.Notification, len(ngn))
|
|
||||||
for _, n := range ngn {
|
|
||||||
if c.idToGroupList[n] == nil {
|
|
||||||
c.idToGroupList[n] = make(map[uint64]struct{})
|
|
||||||
}
|
|
||||||
c.idToGroupList[n][ng.ID] = struct{}{}
|
|
||||||
c.groupToIDList[ng.ID][n] = c.list[n]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
oldList := make(map[uint64]struct{})
|
|
||||||
for nid := range c.groupToIDList[ng.ID] {
|
|
||||||
oldList[nid] = struct{}{}
|
|
||||||
}
|
|
||||||
|
|
||||||
c.groupToIDList[ng.ID] = make(map[uint64]*model.Notification)
|
oldList := c.groupToIDList[ng.ID]
|
||||||
|
newList := make(map[uint64]*model.Notification, len(ngn))
|
||||||
for _, nid := range ngn {
|
for _, nid := range ngn {
|
||||||
c.groupToIDList[ng.ID][nid] = c.list[nid]
|
n, ok := c.list[nid]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newList[nid] = n
|
||||||
if c.idToGroupList[nid] == nil {
|
if c.idToGroupList[nid] == nil {
|
||||||
c.idToGroupList[nid] = make(map[uint64]struct{})
|
c.idToGroupList[nid] = make(map[uint64]struct{})
|
||||||
}
|
}
|
||||||
@@ -167,18 +387,21 @@ func (c *NotificationClass) UpdateGroup(ng *model.NotificationGroup, ngn []uint6
|
|||||||
}
|
}
|
||||||
|
|
||||||
for oldID := range oldList {
|
for oldID := range oldList {
|
||||||
if _, ok := c.groupToIDList[ng.ID][oldID]; !ok {
|
if _, ok := newList[oldID]; ok {
|
||||||
delete(c.groupToIDList[oldID], ng.ID)
|
continue
|
||||||
|
}
|
||||||
|
delete(c.idToGroupList[oldID], ng.ID)
|
||||||
if len(c.idToGroupList[oldID]) == 0 {
|
if len(c.idToGroupList[oldID]) == 0 {
|
||||||
delete(c.idToGroupList, oldID)
|
delete(c.idToGroupList, oldID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
c.groupToIDList[ng.ID] = newList
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *NotificationClass) Delete(idList []uint64) {
|
func (c *NotificationClass) Delete(idList []uint64) {
|
||||||
|
func() {
|
||||||
c.listMu.Lock()
|
c.listMu.Lock()
|
||||||
|
defer c.listMu.Unlock()
|
||||||
|
|
||||||
for _, id := range idList {
|
for _, id := range idList {
|
||||||
delete(c.list, id)
|
delete(c.list, id)
|
||||||
@@ -186,22 +409,27 @@ func (c *NotificationClass) Delete(idList []uint64) {
|
|||||||
if gids, ok := c.idToGroupList[id]; ok {
|
if gids, ok := c.idToGroupList[id]; ok {
|
||||||
for gid := range gids {
|
for gid := range gids {
|
||||||
delete(c.groupToIDList[gid], id)
|
delete(c.groupToIDList[gid], id)
|
||||||
|
}
|
||||||
delete(c.idToGroupList, id)
|
delete(c.idToGroupList, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}()
|
||||||
|
|
||||||
c.listMu.Unlock()
|
|
||||||
c.sortList()
|
c.sortList()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *NotificationClass) DeleteGroup(gids []uint64) {
|
func (c *NotificationClass) DeleteGroup(gids []uint64) {
|
||||||
c.listMu.Lock()
|
|
||||||
defer c.listMu.Unlock()
|
|
||||||
c.groupMu.Lock()
|
c.groupMu.Lock()
|
||||||
defer c.groupMu.Unlock()
|
defer c.groupMu.Unlock()
|
||||||
|
c.listMu.Lock()
|
||||||
|
defer c.listMu.Unlock()
|
||||||
|
|
||||||
for _, gid := range gids {
|
for _, gid := range gids {
|
||||||
|
for nid := range c.groupToIDList[gid] {
|
||||||
|
delete(c.idToGroupList[nid], gid)
|
||||||
|
if len(c.idToGroupList[nid]) == 0 {
|
||||||
|
delete(c.idToGroupList, nid)
|
||||||
|
}
|
||||||
|
}
|
||||||
delete(c.groupList, gid)
|
delete(c.groupList, gid)
|
||||||
delete(c.groupToIDList, gid)
|
delete(c.groupToIDList, gid)
|
||||||
}
|
}
|
||||||
@@ -230,66 +458,115 @@ func (c *NotificationClass) sortList() {
|
|||||||
|
|
||||||
func (c *NotificationClass) UnMuteNotification(notificationGroupID uint64, muteLabel string) {
|
func (c *NotificationClass) UnMuteNotification(notificationGroupID uint64, muteLabel string) {
|
||||||
fullMuteLabel := NotificationMuteLabel.AppendNotificationGroupName(muteLabel, c.GetGroupName(notificationGroupID))
|
fullMuteLabel := NotificationMuteLabel.AppendNotificationGroupName(muteLabel, c.GetGroupName(notificationGroupID))
|
||||||
Cache.Delete(fullMuteLabel)
|
c.listMu.RLock()
|
||||||
|
for id := range c.groupToIDList[notificationGroupID] {
|
||||||
|
key := notificationMethodMuteLabel(fullMuteLabel, id)
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
if c.muteGeneration == nil {
|
||||||
|
c.muteGeneration = make(map[string]uint64)
|
||||||
|
}
|
||||||
|
c.muteGeneration[key]++
|
||||||
|
Cache.Delete(key)
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
}
|
||||||
|
c.listMu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationMethodMuteLabel(label string, id uint64) string {
|
||||||
|
return fmt.Sprintf("%s:method-%d", label, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendNotification 向指定的通知方式组的所有通知方式发送通知
|
// SendNotification 向指定的通知方式组的所有通知方式发送通知
|
||||||
func (c *NotificationClass) SendNotification(notificationGroupID uint64, desc string, muteLabel string, ext ...*model.Server) {
|
func (c *NotificationClass) SendNotification(notificationGroupID uint64, desc string, muteLabel string, ext ...*model.Server) bool {
|
||||||
|
fullMuteLabel := ""
|
||||||
if muteLabel != "" {
|
if muteLabel != "" {
|
||||||
// 将通知方式组名称加入静音标志
|
fullMuteLabel = NotificationMuteLabel.AppendNotificationGroupName(muteLabel, c.GetGroupName(notificationGroupID))
|
||||||
muteLabel := NotificationMuteLabel.AppendNotificationGroupName(muteLabel, c.GetGroupName(notificationGroupID))
|
|
||||||
// 通知防骚扰策略
|
|
||||||
var flag bool
|
|
||||||
if cacheN, has := Cache.Get(muteLabel); has {
|
|
||||||
nHistory := cacheN.(NotificationHistory)
|
|
||||||
// 每次提醒都增加一倍等待时间,最后每天最多提醒一次
|
|
||||||
if time.Now().After(nHistory.Until) {
|
|
||||||
flag = true
|
|
||||||
nHistory.Duration *= 2
|
|
||||||
if nHistory.Duration > time.Hour*24 {
|
|
||||||
nHistory.Duration = time.Hour * 24
|
|
||||||
}
|
}
|
||||||
nHistory.Until = time.Now().Add(nHistory.Duration)
|
// Copy the group under the lock. Webhook delivery can take minutes and must
|
||||||
// 缓存有效期加 10 分钟
|
// not block notification or notification-group updates while it is in flight.
|
||||||
Cache.Set(muteLabel, nHistory, nHistory.Duration+time.Minute*10)
|
c.listMu.RLock()
|
||||||
|
notifications := make([]*model.Notification, 0, len(c.groupToIDList[notificationGroupID]))
|
||||||
|
for _, n := range c.groupToIDList[notificationGroupID] {
|
||||||
|
notifications = append(notifications, n)
|
||||||
}
|
}
|
||||||
} else {
|
c.listMu.RUnlock()
|
||||||
// 新提醒直接通知
|
|
||||||
flag = true
|
if len(notifications) == 0 {
|
||||||
Cache.Set(muteLabel, NotificationHistory{
|
log.Printf("NEZHA>> Notification group %d has no recipients", notificationGroupID)
|
||||||
Duration: firstNotificationDelay,
|
return false
|
||||||
Until: time.Now().Add(firstNotificationDelay),
|
}
|
||||||
}, firstNotificationDelay+time.Minute*10)
|
allDelivered := true
|
||||||
|
for _, n := range notifications {
|
||||||
|
var server *model.Server
|
||||||
|
if len(ext) > 0 {
|
||||||
|
server = ext[0]
|
||||||
|
}
|
||||||
|
if !c.sendRecipient(n, desc, fullMuteLabel, server) {
|
||||||
|
allDelivered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return allDelivered
|
||||||
}
|
}
|
||||||
|
|
||||||
if !flag {
|
func (c *NotificationClass) sendRecipient(n *model.Notification, desc, fullMuteLabel string, server *model.Server) bool {
|
||||||
if Conf.Debug {
|
if n == nil {
|
||||||
log.Println("NEZHA>> Muted repeated notification", desc, muteLabel)
|
return false
|
||||||
|
}
|
||||||
|
cacheKey := ""
|
||||||
|
if fullMuteLabel != "" {
|
||||||
|
cacheKey = notificationMethodMuteLabel(fullMuteLabel, n.ID)
|
||||||
|
}
|
||||||
|
unlock := c.lockDeliveryKey(cacheKey)
|
||||||
|
defer unlock()
|
||||||
|
var generation uint64
|
||||||
|
if cacheKey != "" {
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
generation = c.muteGeneration[cacheKey]
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
}
|
||||||
|
var history NotificationHistory
|
||||||
|
if cacheKey != "" {
|
||||||
|
if value, exists := Cache.Get(cacheKey); exists {
|
||||||
|
history = value.(NotificationHistory)
|
||||||
|
if time.Now().Before(history.Until) {
|
||||||
|
return true // this recipient already received this phase
|
||||||
}
|
}
|
||||||
return
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 向该通知方式组的所有通知方式发出通知
|
|
||||||
c.listMu.RLock()
|
|
||||||
defer c.listMu.RUnlock()
|
|
||||||
for _, n := range c.groupToIDList[notificationGroupID] {
|
|
||||||
log.Printf("NEZHA>> Try to notify %s", n.Name)
|
log.Printf("NEZHA>> Try to notify %s", n.Name)
|
||||||
}
|
var err error
|
||||||
for _, n := range c.groupToIDList[notificationGroupID] {
|
if c.sendForTest != nil {
|
||||||
ns := model.NotificationServerBundle{
|
err = c.sendForTest(n, desc, server)
|
||||||
Notification: n,
|
|
||||||
Server: nil,
|
|
||||||
Loc: Loc,
|
|
||||||
}
|
|
||||||
if len(ext) > 0 {
|
|
||||||
ns.Server = ext[0]
|
|
||||||
}
|
|
||||||
if err := ns.Send(desc); err != nil {
|
|
||||||
log.Printf("NEZHA>> Sending notification to %s failed: %v", n.Name, err)
|
|
||||||
} else {
|
} else {
|
||||||
|
err = (&model.NotificationServerBundle{Notification: n, Server: server, Loc: Loc}).Send(desc)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Webhook errors can contain a credential-bearing request URL. Keep
|
||||||
|
// failure type without ever writing the URL/token to dashboard logs.
|
||||||
|
log.Printf("NEZHA>> Sending notification to %s failed (%s)", n.Name, notificationFailureSummary(err))
|
||||||
|
return false
|
||||||
|
}
|
||||||
log.Printf("NEZHA>> Sending notification to %s succeeded", n.Name)
|
log.Printf("NEZHA>> Sending notification to %s succeeded", n.Name)
|
||||||
|
if cacheKey != "" {
|
||||||
|
if history.Duration == 0 {
|
||||||
|
history.Duration = firstNotificationDelay
|
||||||
|
} else {
|
||||||
|
history.Duration = min(history.Duration*2, 24*time.Hour)
|
||||||
}
|
}
|
||||||
|
history.Until = time.Now().Add(history.Duration)
|
||||||
|
c.deliveryMu.Lock()
|
||||||
|
if c.muteGeneration[cacheKey] == generation {
|
||||||
|
Cache.Set(cacheKey, history, history.Duration+10*time.Minute)
|
||||||
}
|
}
|
||||||
|
c.deliveryMu.Unlock()
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func notificationFailureSummary(err error) string {
|
||||||
|
// Even wrapped URL errors can include credentials in path/query. Log only
|
||||||
|
// the error type; the HTTP status path already returns a dedicated type.
|
||||||
|
return fmt.Sprintf("%T", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func sendSMTPAdminNotification(message string) error {
|
func sendSMTPAdminNotification(message string) error {
|
||||||
|
|||||||
@@ -0,0 +1,519 @@
|
|||||||
|
package singleton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/patrickmn/go-cache"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNotificationFailureDoesNotMuteRetryOrResendSuccessfulRecipient(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
first := &model.Notification{Common: model.Common{ID: 301}, Name: "first"}
|
||||||
|
second := &model.Notification{Common: model.Common{ID: 302}, Name: "second"}
|
||||||
|
nc := newNotificationClassWithItems(first, second)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 303}, Name: "retry-test"}, []uint64{first.ID, second.ID})
|
||||||
|
counts := map[uint64]int{}
|
||||||
|
nc.sendForTest = func(n *model.Notification, _ string, _ *model.Server) error {
|
||||||
|
counts[n.ID]++
|
||||||
|
if n.ID == second.ID && counts[n.ID] == 1 {
|
||||||
|
return errors.New("temporary failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
label := "alert-retry-test"
|
||||||
|
nc.UnMuteNotification(303, label)
|
||||||
|
if nc.SendNotification(303, "incident", label) {
|
||||||
|
t.Fatal("partial delivery must report failure")
|
||||||
|
}
|
||||||
|
if !nc.SendNotification(303, "incident", label) {
|
||||||
|
t.Fatal("failed recipient should be retried successfully")
|
||||||
|
}
|
||||||
|
if counts[first.ID] != 1 || counts[second.ID] != 2 {
|
||||||
|
t.Fatalf("delivery attempts = %v, want first=1 second=2", counts)
|
||||||
|
}
|
||||||
|
nc.UnMuteNotification(303, label)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationConcurrentSameRecipientSendsOnce(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 401}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 402}, Name: "concurrent-test"}, []uint64{n.ID})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls atomic.Int32
|
||||||
|
nc.sendForTest = func(*model.Notification, string, *model.Server) error {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
done := make(chan bool, 2)
|
||||||
|
go func() { done <- nc.SendNotification(402, "incident", "same-event") }()
|
||||||
|
<-entered
|
||||||
|
go func() { done <- nc.SendNotification(402, "incident", "same-event") }()
|
||||||
|
close(release)
|
||||||
|
if !<-done || !<-done {
|
||||||
|
t.Fatal("concurrent delivery returned failure")
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("same event delivered %d times, want 1", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationAsyncCoalescesBlockedServiceReports(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 501}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 502}, Name: "service-test"}, []uint64{n.ID})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls atomic.Int32
|
||||||
|
nc.sendForTest = func(*model.Notification, string, *model.Server) error {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nc.SendNotificationAsync(502, "down", "service-down")
|
||||||
|
<-entered
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
nc.SendNotificationAsync(502, "down", "service-down")
|
||||||
|
}
|
||||||
|
nc.deliveryMu.Lock()
|
||||||
|
inFlight := len(nc.asyncInFlight)
|
||||||
|
nc.deliveryMu.Unlock()
|
||||||
|
if inFlight != 1 || calls.Load() != 1 {
|
||||||
|
t.Fatalf("in-flight sends=%d attempts=%d, want one each", inFlight, calls.Load())
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
deadline := time.After(time.Second)
|
||||||
|
for {
|
||||||
|
nc.deliveryMu.Lock()
|
||||||
|
remaining := len(nc.asyncInFlight)
|
||||||
|
nc.deliveryMu.Unlock()
|
||||||
|
if remaining == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("async notification did not finish")
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationFailureSummaryOmitsCredentialURL(t *testing.T) {
|
||||||
|
err := &url.Error{Op: "Post", URL: "https://example.test/bot-secret-token/send?key=secret-query", Err: errors.New("timeout")}
|
||||||
|
if summary := notificationFailureSummary(err); strings.Contains(summary, "secret") || strings.Contains(summary, "example.test") {
|
||||||
|
t.Fatalf("credential-bearing URL leaked: %s", summary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationAsyncBacksOffFastFailures(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 511}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 512}, Name: "backoff-test"}, []uint64{n.ID})
|
||||||
|
var calls atomic.Int32
|
||||||
|
nc.sendForTest = func(*model.Notification, string, *model.Server) error {
|
||||||
|
calls.Add(1)
|
||||||
|
return errors.New("fast failure")
|
||||||
|
}
|
||||||
|
key := "512:service-down"
|
||||||
|
nc.SendNotificationAsync(512, "down", "service-down")
|
||||||
|
deadline := time.After(time.Second)
|
||||||
|
for {
|
||||||
|
nc.deliveryMu.Lock()
|
||||||
|
busy := len(nc.asyncInFlight) != 0
|
||||||
|
after := nc.asyncRetryAfter[key]
|
||||||
|
nc.deliveryMu.Unlock()
|
||||||
|
if !busy && !after.IsZero() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("first failed send did not record backoff")
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := 0; i < 1000; i++ {
|
||||||
|
nc.SendNotificationAsync(512, "down", "service-down")
|
||||||
|
}
|
||||||
|
if calls.Load() != 1 {
|
||||||
|
t.Fatalf("fast failures caused %d attempts before retry-after", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnMuteDuringInflightSendCannotRemuteRecovery(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 521}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 522}, Name: "generation-test"}, []uint64{n.ID})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls atomic.Int32
|
||||||
|
nc.sendForTest = func(*model.Notification, string, *model.Server) error {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() { done <- nc.SendNotification(522, "first failure", "static-label") }()
|
||||||
|
<-entered
|
||||||
|
nc.UnMuteNotification(522, "static-label")
|
||||||
|
close(release)
|
||||||
|
if !<-done {
|
||||||
|
t.Fatal("first send failed unexpectedly")
|
||||||
|
}
|
||||||
|
if !nc.SendNotification(522, "next failure", "static-label") || calls.Load() != 2 {
|
||||||
|
t.Fatalf("old in-flight send re-muted next failure: calls=%d", calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAsyncBackoffMemoryIsBounded(t *testing.T) {
|
||||||
|
nc := &NotificationClass{
|
||||||
|
asyncRetryAfter: make(map[string]time.Time),
|
||||||
|
asyncRetryDelay: make(map[string]time.Duration),
|
||||||
|
}
|
||||||
|
for i := 0; i < maxAsyncBackoffKeys+100; i++ {
|
||||||
|
key := fmt.Sprintf("old-event-%d", i)
|
||||||
|
nc.asyncRetryAfter[key] = time.Now().Add(time.Minute)
|
||||||
|
nc.asyncRetryDelay[key] = time.Minute
|
||||||
|
}
|
||||||
|
nc.pruneAsyncBackoffLocked()
|
||||||
|
if len(nc.asyncRetryAfter) > maxAsyncBackoffKeys || len(nc.asyncRetryDelay) != len(nc.asyncRetryAfter) {
|
||||||
|
t.Fatalf("unbounded backoff maps: after=%d delay=%d", len(nc.asyncRetryAfter), len(nc.asyncRetryDelay))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceStateNotificationsKeepIncidentRecoveryOrder(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 531}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 532}, Name: "ordered-test"}, []uint64{n.ID})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
delivered := make(chan string, 3)
|
||||||
|
nc.sendForTest = func(_ *model.Notification, desc string, _ *model.Server) error {
|
||||||
|
if desc == "down" {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
delivered <- desc
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
nc.SendServiceState(533, 532, "down", "state-down-1")
|
||||||
|
<-entered
|
||||||
|
nc.SendServiceState(533, 532, "good", "state-good-2")
|
||||||
|
select {
|
||||||
|
case message := <-delivered:
|
||||||
|
t.Fatalf("service transition overtook blocked incident: %s", message)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
for _, want := range []string{"down", "good"} {
|
||||||
|
select {
|
||||||
|
case got := <-delivered:
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("delivery order got %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("missing %s delivery", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
deadline := time.After(time.Second)
|
||||||
|
for {
|
||||||
|
nc.deliveryMu.Lock()
|
||||||
|
remaining := len(nc.serviceDelivery)
|
||||||
|
nc.deliveryMu.Unlock()
|
||||||
|
if remaining == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("ordered delivery worker did not exit")
|
||||||
|
case <-time.After(time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceStateRetriesFailedIncidentBeforePendingRecovery(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 535}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 536}, Name: "retry-order-test"}, []uint64{n.ID})
|
||||||
|
t.Cleanup(func() { nc.CancelServiceState(537) })
|
||||||
|
|
||||||
|
attempts := make(chan string, 3)
|
||||||
|
releaseFirst := make(chan struct{})
|
||||||
|
var downAttempts atomic.Int32
|
||||||
|
nc.sendForTest = func(_ *model.Notification, desc string, _ *model.Server) error {
|
||||||
|
attempts <- desc
|
||||||
|
if desc == "down" && downAttempts.Add(1) == 1 {
|
||||||
|
<-releaseFirst
|
||||||
|
return errors.New("temporary incident failure")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
nc.SendServiceState(537, 536, "down", "state-down-1")
|
||||||
|
nc.deliveryMu.Lock()
|
||||||
|
state := nc.serviceDelivery[537]
|
||||||
|
nc.deliveryMu.Unlock()
|
||||||
|
select {
|
||||||
|
case got := <-attempts:
|
||||||
|
if got != "down" {
|
||||||
|
t.Fatalf("first delivery = %q, want down", got)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("incident delivery did not start")
|
||||||
|
}
|
||||||
|
nc.SendServiceState(537, 536, "good", "state-good-2")
|
||||||
|
close(releaseFirst)
|
||||||
|
|
||||||
|
for _, want := range []string{"down", "good"} {
|
||||||
|
select {
|
||||||
|
case got := <-attempts:
|
||||||
|
if got != want {
|
||||||
|
t.Fatalf("delivery after failed incident = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatalf("missing %s delivery", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-state.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("ordered delivery worker did not finish")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceStateCancelStopsOldConfiguration(t *testing.T) {
|
||||||
|
oldCache := Cache
|
||||||
|
Cache = cache.New(time.Minute, time.Minute)
|
||||||
|
t.Cleanup(func() { Cache = oldCache })
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 541}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 542}, Name: "old-group"}, []uint64{n.ID})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
var calls atomic.Int32
|
||||||
|
nc.sendForTest = func(*model.Notification, string, *model.Server) error {
|
||||||
|
if calls.Add(1) == 1 {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
}
|
||||||
|
return errors.New("old webhook failed")
|
||||||
|
}
|
||||||
|
nc.SendServiceState(543, 542, "down", "old-event")
|
||||||
|
<-entered
|
||||||
|
nc.deliveryMu.Lock()
|
||||||
|
state := nc.serviceDelivery[543]
|
||||||
|
nc.deliveryMu.Unlock()
|
||||||
|
nc.CancelServiceState(543)
|
||||||
|
nc.deliveryMu.Lock()
|
||||||
|
remaining := len(nc.serviceDelivery)
|
||||||
|
nc.deliveryMu.Unlock()
|
||||||
|
if remaining != 0 {
|
||||||
|
t.Fatal("old service delivery worker remained registered after cancellation")
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
select {
|
||||||
|
case <-state.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("canceled old delivery worker did not exit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServiceUpdateCancelsOldRecipientRetry(t *testing.T) {
|
||||||
|
ss := newServiceMonitorSecurityHarness(t)
|
||||||
|
service := &model.Service{
|
||||||
|
Common: model.Common{ID: 551, UserID: 1},
|
||||||
|
Name: "generic-probe",
|
||||||
|
Type: model.TaskTypeTCPPing,
|
||||||
|
Target: "example.invalid:443",
|
||||||
|
Duration: 3600,
|
||||||
|
Notify: true,
|
||||||
|
NotificationGroupID: 552,
|
||||||
|
Cover: model.ServiceCoverIgnoreAll,
|
||||||
|
}
|
||||||
|
addServiceMonitorSecurityService(t, ss, service)
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 553}, Name: "old-hook"}
|
||||||
|
NotificationShared.InsertForTest(n)
|
||||||
|
NotificationShared.UpdateGroup(&model.NotificationGroup{Common: model.Common{ID: 552}, Name: "old-group"}, []uint64{n.ID})
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
NotificationShared.sendForTest = func(*model.Notification, string, *model.Server) error {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
return errors.New("failed old recipient")
|
||||||
|
}
|
||||||
|
NotificationShared.SendServiceState(service.ID, 552, "old down", "old-state")
|
||||||
|
<-entered
|
||||||
|
NotificationShared.deliveryMu.Lock()
|
||||||
|
state := NotificationShared.serviceDelivery[service.ID]
|
||||||
|
NotificationShared.deliveryMu.Unlock()
|
||||||
|
updated := *service
|
||||||
|
updated.Notify = false
|
||||||
|
updated.NotificationGroupID = 554
|
||||||
|
if err := ss.Update(&updated); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
close(release)
|
||||||
|
select {
|
||||||
|
case <-state.done:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("old recipient retry did not stop after service update")
|
||||||
|
}
|
||||||
|
NotificationShared.deliveryMu.Lock()
|
||||||
|
remaining := len(NotificationShared.serviceDelivery)
|
||||||
|
NotificationShared.deliveryMu.Unlock()
|
||||||
|
if remaining != 0 {
|
||||||
|
t.Fatal("old recipient retained after service update")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNotificationClassWithItems(items ...*model.Notification) *NotificationClass {
|
||||||
|
nc := NewEmptyNotificationClassForTest()
|
||||||
|
for _, item := range items {
|
||||||
|
nc.InsertForTest(item)
|
||||||
|
}
|
||||||
|
return nc
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationClassDeleteGroupCleansReverseIndex(t *testing.T) {
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 1, UserID: 2}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
group := &model.NotificationGroup{Common: model.Common{ID: 10, UserID: 2}, Name: "group"}
|
||||||
|
nc.UpdateGroup(group, []uint64{n.ID})
|
||||||
|
|
||||||
|
nc.DeleteGroup([]uint64{group.ID})
|
||||||
|
|
||||||
|
if _, ok := nc.groupToIDList[group.ID]; ok {
|
||||||
|
t.Fatalf("forward index still contains deleted group %d", group.ID)
|
||||||
|
}
|
||||||
|
if groups, ok := nc.idToGroupList[n.ID]; ok {
|
||||||
|
if _, stale := groups[group.ID]; stale {
|
||||||
|
t.Fatalf("reverse index for notification %d still contains deleted group %d", n.ID, group.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationClassUpdateGroupCleansRemovedMembership(t *testing.T) {
|
||||||
|
first := &model.Notification{Common: model.Common{ID: 1, UserID: 2}, Name: "first"}
|
||||||
|
second := &model.Notification{Common: model.Common{ID: 2, UserID: 2}, Name: "second"}
|
||||||
|
nc := newNotificationClassWithItems(first, second)
|
||||||
|
group := &model.NotificationGroup{Common: model.Common{ID: 10, UserID: 2}, Name: "group"}
|
||||||
|
nc.UpdateGroup(group, []uint64{first.ID, second.ID})
|
||||||
|
|
||||||
|
nc.UpdateGroup(group, []uint64{second.ID})
|
||||||
|
|
||||||
|
if groups, ok := nc.idToGroupList[first.ID]; ok {
|
||||||
|
if _, stale := groups[group.ID]; stale {
|
||||||
|
t.Fatalf("reverse index for notification %d still contains group %d", first.ID, group.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, ok := nc.idToGroupList[second.ID][group.ID]; !ok {
|
||||||
|
t.Fatalf("reverse index lost retained membership of notification %d in group %d", second.ID, group.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationClassUpdateRepairsOrphanedReverseIndex(t *testing.T) {
|
||||||
|
n := &model.Notification{Common: model.Common{ID: 1, UserID: 2}, Name: "hook"}
|
||||||
|
nc := newNotificationClassWithItems(n)
|
||||||
|
nc.idToGroupList[n.ID] = map[uint64]struct{}{99: {}}
|
||||||
|
|
||||||
|
n.Name = "updated"
|
||||||
|
nc.Update(n)
|
||||||
|
|
||||||
|
if groups, ok := nc.idToGroupList[n.ID]; ok && len(groups) != 0 {
|
||||||
|
t.Fatalf("orphaned reverse memberships were not removed: %v", groups)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNotificationClassGroupMutationsDoNotDeadlock(t *testing.T) {
|
||||||
|
nc := NewEmptyNotificationClassForTest()
|
||||||
|
nc.listMu.RLock()
|
||||||
|
readLockHeld := true
|
||||||
|
defer func() {
|
||||||
|
if readLockHeld {
|
||||||
|
nc.listMu.RUnlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
deleteDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
nc.DeleteGroup([]uint64{10})
|
||||||
|
close(deleteDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline := time.Now().Add(2 * time.Second)
|
||||||
|
for nc.listMu.TryRLock() {
|
||||||
|
nc.listMu.RUnlock()
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("DeleteGroup did not queue for listMu")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
nc.UpdateGroup(&model.NotificationGroup{
|
||||||
|
Common: model.Common{ID: 10, UserID: 2},
|
||||||
|
Name: "group",
|
||||||
|
}, nil)
|
||||||
|
close(updateDone)
|
||||||
|
}()
|
||||||
|
|
||||||
|
deadline = time.Now().Add(2 * time.Second)
|
||||||
|
for nc.groupMu.TryLock() {
|
||||||
|
nc.groupMu.Unlock()
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
t.Fatal("neither group mutation acquired groupMu")
|
||||||
|
}
|
||||||
|
time.Sleep(time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
nc.listMu.RUnlock()
|
||||||
|
readLockHeld = false
|
||||||
|
|
||||||
|
timer := time.NewTimer(2 * time.Second)
|
||||||
|
defer timer.Stop()
|
||||||
|
for deleteDone != nil || updateDone != nil {
|
||||||
|
select {
|
||||||
|
case <-deleteDone:
|
||||||
|
deleteDone = nil
|
||||||
|
case <-updateDone:
|
||||||
|
updateDone = nil
|
||||||
|
case <-timer.C:
|
||||||
|
t.Fatal("concurrent UpdateGroup and DeleteGroup deadlocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package singleton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OnlineHistoryRecorder 维护每台服务器当前进行中的在线会话。会话的打开与
|
||||||
|
// 关闭由 agent 状态流的建立/断开驱动(service/rpc),并由定时清扫兜底关闭
|
||||||
|
// 失联(未正常断流)的会话;dashboard 重启时通过数据库中 Ongoing 会话恢复。
|
||||||
|
type OnlineHistoryRecorder struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
restored bool
|
||||||
|
open map[uint64]*onlineSessionRuntime
|
||||||
|
// closing 是关库失败后的待关闭状态:断线边界已被观察到,会话 ID 与
|
||||||
|
// 结束时间在此固定、不可被后续上报覆盖;重试只为旧会话完成持久化,
|
||||||
|
// 重连不得把它续接成活跃会话(同服务器可能同时有多条待关闭会话)。
|
||||||
|
closing map[uint64][]*onlineSessionClosing
|
||||||
|
// Preserve the first disconnect while startup reads are unavailable.
|
||||||
|
recoveryDisconnects map[uint64]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type onlineSessionRuntime struct {
|
||||||
|
id uint64
|
||||||
|
lastObserved time.Time
|
||||||
|
revision uint64
|
||||||
|
// lastPersist 是 LastActiveAt 最近一次落库时间,用于节流写库。
|
||||||
|
lastPersist time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type onlineSessionClosing struct {
|
||||||
|
id uint64
|
||||||
|
// end 是已观察到的会话结束时刻,重试持久化时原样使用。
|
||||||
|
end time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var OnlineHistoryShared *OnlineHistoryRecorder
|
||||||
|
|
||||||
|
func NewOnlineHistoryRecorder() *OnlineHistoryRecorder {
|
||||||
|
r := &OnlineHistoryRecorder{
|
||||||
|
open: make(map[uint64]*onlineSessionRuntime),
|
||||||
|
closing: make(map[uint64][]*onlineSessionClosing),
|
||||||
|
recoveryDisconnects: make(map[uint64]time.Time),
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
r.restoreOngoingSessions()
|
||||||
|
r.mu.Unlock()
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreOngoingSessions 每台服务器仅允许最新且仍在在线窗口内的会话续接,
|
||||||
|
// 其余会话按最后持久化的活跃时间结转。崩溃会丢失内存中的待关闭边界,
|
||||||
|
// 恢复时采用 LastActiveAt 保守结转,不推测未持久化的断线时间。
|
||||||
|
func (r *OnlineHistoryRecorder) restoreOngoingSessions() bool {
|
||||||
|
if DB == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var ongoing []model.OnlineSession
|
||||||
|
if err := DB.Where("ongoing = ?", true).
|
||||||
|
Order("server_id ASC").Order("started_at DESC").Order("id DESC").Find(&ongoing).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to load ongoing online sessions: %v", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
seenServers := make(map[uint64]bool)
|
||||||
|
for _, s := range ongoing {
|
||||||
|
// 关闭失败后重连可留下同服务器多条 Ongoing 行;直接赋值 open 会覆盖
|
||||||
|
// 旧 ID,导致其永久脱管。按开始时间(同时间按 ID)选最新行,其余
|
||||||
|
// 必须关闭或进入 closing 重试,即使最新行本身已经过期也不能复活旧行。
|
||||||
|
duplicate := seenServers[s.ServerID]
|
||||||
|
seenServers[s.ServerID] = true
|
||||||
|
disconnect, observedDisconnect := r.recoveryDisconnects[s.ServerID]
|
||||||
|
observedDisconnect = observedDisconnect && !duplicate && !disconnect.Before(s.StartedAt)
|
||||||
|
if observedDisconnect || duplicate || s.LastActiveAt.Before(now.Add(-model.OnlineActiveWindow)) {
|
||||||
|
end := s.LastActiveAt
|
||||||
|
if observedDisconnect && disconnect.After(end) {
|
||||||
|
end = disconnect
|
||||||
|
}
|
||||||
|
if end.Before(s.StartedAt) {
|
||||||
|
end = s.StartedAt
|
||||||
|
}
|
||||||
|
if err := closeOnlineSessionRow(s.ID, end); err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to close recovered online session %d: %v", s.ID, err)
|
||||||
|
// 关库失败不能跳过,否则会留下永不闭合、也无人重试的
|
||||||
|
// Ongoing 行;进入待关闭状态交由清扫周期重试。过期或
|
||||||
|
// 被更新会话替代的行均不得被重连续接为活跃会话
|
||||||
|
r.closing[s.ServerID] = append(r.closing[s.ServerID],
|
||||||
|
&onlineSessionClosing{id: s.ID, end: end})
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.open[s.ServerID] = &onlineSessionRuntime{id: s.ID, lastPersist: s.LastActiveAt, lastObserved: s.LastActiveAt}
|
||||||
|
}
|
||||||
|
clear(r.recoveryDisconnects)
|
||||||
|
r.restored = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// flushClosing 尽力重试关闭该服务器的待关闭会话:按入列时固定的结束时间
|
||||||
|
// 持久化,成功才移除;失败的条目原样保留,结束时间不得被后续上报改写。
|
||||||
|
func (r *OnlineHistoryRecorder) flushClosing(serverID uint64) {
|
||||||
|
pendings, ok := r.closing[serverID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
kept := pendings[:0]
|
||||||
|
for _, pc := range pendings {
|
||||||
|
if err := closeOnlineSessionRow(pc.id, pc.end); err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to retry closing online session %d: %v", pc.id, err)
|
||||||
|
kept = append(kept, pc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
|
delete(r.closing, serverID)
|
||||||
|
} else {
|
||||||
|
r.closing[serverID] = kept
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOnline 记录一次状态上报:无进行中会话时开启新会话(重启遗留且仍在
|
||||||
|
// 窗口内的 Ongoing 会话直接续接);已有会话时节流落库 LastActiveAt。
|
||||||
|
func (r *OnlineHistoryRecorder) RecordOnline(serverID uint64, at time.Time) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
// A failed startup read is not an empty history: retry before accepting reports.
|
||||||
|
if !r.restored && !r.restoreOngoingSessions() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
at = at.UTC()
|
||||||
|
r.flushClosing(serverID)
|
||||||
|
|
||||||
|
// A report can arrive after expiry but before the next sweep. Close at the
|
||||||
|
// previous observation before overwriting it, preserving failed-close retries.
|
||||||
|
if rt := r.open[serverID]; rt != nil && at.Sub(rt.lastObserved) > model.OnlineActiveWindow {
|
||||||
|
r.recordOfflineLocked(serverID, rt.lastObserved)
|
||||||
|
}
|
||||||
|
if rt, ok := r.open[serverID]; ok {
|
||||||
|
rt.lastObserved = at
|
||||||
|
rt.revision++
|
||||||
|
if at.Sub(rt.lastPersist) >= model.OnlineActiveWindow {
|
||||||
|
if err := DB.Model(&model.OnlineSession{}).Where("id = ?", rt.id).Update("last_active_at", at).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to persist online session last_active_at: %v", err)
|
||||||
|
} else {
|
||||||
|
// Only committed writes advance the throttle; failures retry on the next report.
|
||||||
|
rt.lastPersist = at
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// All ongoing rows are owned by open or closing after successful recovery.
|
||||||
|
|
||||||
|
s := model.OnlineSession{ServerID: serverID, StartedAt: at, LastActiveAt: at, Ongoing: true}
|
||||||
|
if err := DB.Create(&s).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to create online session: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.open[serverID] = &onlineSessionRuntime{id: s.ID, lastPersist: at, lastObserved: at}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordOffline 关闭该服务器当前进行中的会话。at 是会话结束时刻:正常断流
|
||||||
|
// 为当前时间,清扫/结转为最后一次已知活跃时间。
|
||||||
|
// 关库失败时把会话转入待关闭状态而非保留为活跃条目:会话 ID 与已观察到的
|
||||||
|
// 结束时刻(at 与 lastPersist 的较大者)就此固定,后续上报不得覆盖;重试
|
||||||
|
// 只为旧会话完成持久化,重连则开启新会话——断线后的离线间隔不得计作在线。
|
||||||
|
func (r *OnlineHistoryRecorder) RecordOffline(serverID uint64, at time.Time) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
// Disconnect may be the first event after startup storage recovers.
|
||||||
|
if !r.restored && !r.restoreOngoingSessions() {
|
||||||
|
// Retrying SELECT alone loses confirmed offline gaps on a quick reconnect.
|
||||||
|
// Keep the first boundary; subsequent disconnected streams cannot extend it.
|
||||||
|
if r.recoveryDisconnects == nil {
|
||||||
|
r.recoveryDisconnects = make(map[uint64]time.Time)
|
||||||
|
}
|
||||||
|
if _, exists := r.recoveryDisconnects[serverID]; !exists {
|
||||||
|
r.recoveryDisconnects[serverID] = at.UTC()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.recordOfflineLocked(serverID, at)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *OnlineHistoryRecorder) recordOfflineLocked(serverID uint64, at time.Time) {
|
||||||
|
rt, ok := r.open[serverID]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
end := at
|
||||||
|
if end.Before(rt.lastPersist) {
|
||||||
|
end = rt.lastPersist
|
||||||
|
}
|
||||||
|
if err := closeOnlineSessionRow(rt.id, end); err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to close online session %d: %v", rt.id, err)
|
||||||
|
// 结束时刻已观察到,不能因写库失败丢失,也不能把行继续当活跃
|
||||||
|
// 会话:转待关闭状态,由 RecordOnline 冲洗与清扫周期重试
|
||||||
|
r.closing[serverID] = append(r.closing[serverID], &onlineSessionClosing{id: rt.id, end: end})
|
||||||
|
delete(r.open, serverID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(r.open, serverID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepStale 关闭超过在线窗口仍无状态上报的进行中会话。lastActiveFn 返回
|
||||||
|
// 服务器的运行时最后活跃时间与是否存在。
|
||||||
|
func (r *OnlineHistoryRecorder) SweepStale(now time.Time, lastActiveFn func(serverID uint64) (time.Time, bool)) {
|
||||||
|
// Never call the runtime observer while holding mu: reports acquire the
|
||||||
|
// runtime lease before this mutex. Snapshot identities, then revalidate them.
|
||||||
|
r.mu.Lock()
|
||||||
|
if !r.restored && !r.restoreOngoingSessions() {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
candidates := make(map[uint64]onlineSessionRuntime, len(r.open))
|
||||||
|
for id, rt := range r.open {
|
||||||
|
candidates[id] = *rt
|
||||||
|
}
|
||||||
|
for id := range r.closing {
|
||||||
|
r.flushClosing(id)
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
for id, candidate := range candidates {
|
||||||
|
lastActive, ok := lastActiveFn(id)
|
||||||
|
r.mu.Lock()
|
||||||
|
rt := r.open[id]
|
||||||
|
if rt != nil && rt.id == candidate.id && rt.revision == candidate.revision {
|
||||||
|
if lastActive.Before(rt.lastObserved) {
|
||||||
|
lastActive = rt.lastObserved
|
||||||
|
}
|
||||||
|
if (!ok || lastActive.Before(now.Add(-model.OnlineActiveWindow))) && rt.lastObserved.Before(now.Add(-model.OnlineActiveWindow)) {
|
||||||
|
r.recordOfflineLocked(id, lastActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// SweepOnlineSessions 是定时清扫入口,接上服务器运行时的最后活跃时间。
|
||||||
|
func SweepOnlineSessions() {
|
||||||
|
if OnlineHistoryShared == nil || ServerShared == nil || DB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
OnlineHistoryShared.SweepStale(time.Now(), onlineServerLastActive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func onlineServerLastActive(serverID uint64) (time.Time, bool) {
|
||||||
|
server, ok := ServerShared.Get(serverID)
|
||||||
|
if !ok || server == nil {
|
||||||
|
return time.Time{}, false
|
||||||
|
}
|
||||||
|
lastActive := server.RuntimeSnapshot().LastActive
|
||||||
|
return lastActive, !lastActive.IsZero()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PruneOnlineSessions 清理超出统计窗口的已结束会话与已删除服务器的残留记录。
|
||||||
|
// 时间列直接比较(与写入时的绑定格式同构)以走 ended_at 索引,并用分批
|
||||||
|
// DELETE 替换单条巨型删除,避免大表上长时间占用 sqlite 写锁、阻塞上报路径。
|
||||||
|
func PruneOnlineSessions() {
|
||||||
|
if DB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
keepDays := Conf.OnlineHistoryRetentionDays()
|
||||||
|
if keepDays < model.MinOnlineStatsDays || keepDays > model.MaxOnlineStatsDays {
|
||||||
|
keepDays = model.DefaultOnlineStatsDays
|
||||||
|
}
|
||||||
|
cutoff := model.OnlineHistoryWindowStart(time.Now(), keepDays, Loc)
|
||||||
|
pruneOnlineSessionsBatch("ongoing = ? AND ended_at < ?", false, cutoff)
|
||||||
|
pruneOnlineSessionsBatch("server_id NOT IN (SELECT `id` FROM servers)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pruneOnlineSessionsBatch 按主键分批删除匹配的行,每批之间释放写锁。
|
||||||
|
func pruneOnlineSessionsBatch(where string, args ...any) {
|
||||||
|
const batchSize = 10000
|
||||||
|
for {
|
||||||
|
var ids []uint64
|
||||||
|
if err := DB.Model(&model.OnlineSession{}).Unscoped().
|
||||||
|
Where(where, args...).Limit(batchSize).Pluck("id", &ids).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to select online sessions for pruning: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := DB.Unscoped().Delete(&model.OnlineSession{}, ids).Error; err != nil {
|
||||||
|
log.Printf("NEZHA>> Failed to prune online sessions: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(ids) < batchSize {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeOnlineSessionRow(id uint64, end time.Time) error {
|
||||||
|
return DB.Model(&model.OnlineSession{}).Where("id = ?", id).Updates(map[string]any{
|
||||||
|
"ended_at": end.UTC(),
|
||||||
|
"ongoing": false,
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sessions snapshots rows and volatile close boundaries under the same lock.
|
||||||
|
// Otherwise a failed close can count confirmed offline time as ongoing, or a
|
||||||
|
// successful retry can remove the boundary after the reader fetched a stale row.
|
||||||
|
func (r *OnlineHistoryRecorder) Sessions(serverID uint64, start, end time.Time) ([]model.OnlineSession, error) {
|
||||||
|
if r != nil {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if !r.restored && !r.restoreOngoingSessions() {
|
||||||
|
return nil, fmt.Errorf("online history recovery unavailable")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var sessions []model.OnlineSession
|
||||||
|
// Separate branches use (server_id, ongoing, ended_at) and
|
||||||
|
// (server_id, ongoing, started_at), avoiding a scan of retained closed rows.
|
||||||
|
for _, ongoing := range []bool{false, true} {
|
||||||
|
var rows []model.OnlineSession
|
||||||
|
q := DB.Where("server_id = ? AND ongoing = ?", serverID, ongoing)
|
||||||
|
if ongoing {
|
||||||
|
q = q.Where("started_at < ?", end.UTC())
|
||||||
|
} else {
|
||||||
|
q = q.Where("ended_at > ?", start.UTC())
|
||||||
|
}
|
||||||
|
if err := q.Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
sessions = append(sessions, rows...)
|
||||||
|
}
|
||||||
|
if r != nil {
|
||||||
|
endings := make(map[uint64]time.Time)
|
||||||
|
for _, pending := range r.closing[serverID] {
|
||||||
|
endings[pending.id] = pending.end
|
||||||
|
}
|
||||||
|
for i := range sessions {
|
||||||
|
if end, ok := endings[sessions[i].ID]; ok {
|
||||||
|
sessions[i].Ongoing = false
|
||||||
|
sessions[i].EndedAt = end
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(sessions, func(i, j int) bool { return sessions[i].StartedAt.Before(sessions[j].StartedAt) })
|
||||||
|
return sessions, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package singleton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOnlineHistoryRetriesFailedHeartbeatImmediately(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
start := time.Now().UTC().Truncate(time.Second)
|
||||||
|
r.RecordOnline(1, start)
|
||||||
|
id := r.open[1].id
|
||||||
|
require.NoError(t, DB.Exec(`CREATE TRIGGER reject_heartbeat BEFORE UPDATE OF last_active_at ON online_sessions BEGIN SELECT RAISE(ABORT, 'heartbeat failure'); END`).Error)
|
||||||
|
r.RecordOnline(1, start.Add(30*time.Second))
|
||||||
|
require.NoError(t, DB.Exec("DROP TRIGGER reject_heartbeat").Error)
|
||||||
|
r.RecordOnline(1, start.Add(31*time.Second))
|
||||||
|
require.True(t, getOnlineSession(t, id).LastActiveAt.Equal(start.Add(31*time.Second)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryRetriesStartupReadBeforeNewReport(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
start := time.Now().UTC().Add(-time.Hour).Truncate(time.Second)
|
||||||
|
old := model.OnlineSession{ServerID: 1, StartedAt: start, LastActiveAt: start, Ongoing: true}
|
||||||
|
require.NoError(t, DB.Create(&old).Error)
|
||||||
|
require.NoError(t, DB.Callback().Query().Before("gorm:query").Register("reject_history_restore", func(db *gorm.DB) { db.AddError(errors.New("restore unavailable")) }))
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
require.NoError(t, DB.Callback().Query().Remove("reject_history_restore"))
|
||||||
|
r.RecordOnline(1, time.Now())
|
||||||
|
stored := getOnlineSession(t, old.ID)
|
||||||
|
require.False(t, stored.Ongoing)
|
||||||
|
require.True(t, stored.EndedAt.Equal(start))
|
||||||
|
require.NotEqual(t, old.ID, r.open[1].id)
|
||||||
|
require.Equal(t, int64(1), countOnlineSessions(t, "ongoing = ?", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistorySweepKeepsSessionReconnectedDuringObservation(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
start := time.Now().Add(-time.Hour)
|
||||||
|
r.RecordOnline(1, start)
|
||||||
|
observed := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
r.SweepStale(time.Now(), func(uint64) (time.Time, bool) { close(observed); <-release; return start, true })
|
||||||
|
}()
|
||||||
|
<-observed
|
||||||
|
reconnected := make(chan struct{})
|
||||||
|
go func() { r.RecordOffline(1, start.Add(time.Second)); r.RecordOnline(1, time.Now()); close(reconnected) }()
|
||||||
|
// The observer must not hold the recorder lock: RPC updates take the runtime
|
||||||
|
// lock first and then enter the recorder. Holding both in reverse order deadlocks.
|
||||||
|
select {
|
||||||
|
case <-reconnected:
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
close(release)
|
||||||
|
<-done
|
||||||
|
<-reconnected
|
||||||
|
t.Fatal("sweep holds recorder lock while consulting runtime state")
|
||||||
|
}
|
||||||
|
id := r.open[1].id
|
||||||
|
close(release)
|
||||||
|
<-done
|
||||||
|
require.Contains(t, r.open, uint64(1))
|
||||||
|
require.Equal(t, id, r.open[1].id)
|
||||||
|
require.True(t, getOnlineSession(t, id).Ongoing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryQueryNormalizesOffsetsAndUsesWindowIndexes(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
east := time.FixedZone("UTC+8", 8*3600)
|
||||||
|
start := time.Date(2025, 1, 1, 12, 0, 0, 0, east)
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
r.RecordOnline(1, start)
|
||||||
|
r.RecordOffline(1, start.Add(time.Hour))
|
||||||
|
rows, err := r.Sessions(1, start.UTC().Add(-time.Minute), start.UTC().Add(2*time.Hour))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 1)
|
||||||
|
require.True(t, rows[0].StartedAt.Equal(start))
|
||||||
|
require.True(t, rows[0].EndedAt.Equal(start.Add(time.Hour)))
|
||||||
|
for _, test := range []struct {
|
||||||
|
query, index string
|
||||||
|
args []any
|
||||||
|
}{
|
||||||
|
{"EXPLAIN QUERY PLAN SELECT * FROM online_sessions WHERE server_id = ? AND ongoing = ? AND ended_at > ?", "idx_online_sessions_server_closed", []any{1, false, start.UTC()}},
|
||||||
|
{"EXPLAIN QUERY PLAN SELECT * FROM online_sessions WHERE server_id = ? AND ongoing = ? AND started_at < ?", "idx_online_sessions_server_open", []any{1, true, start.Add(2 * time.Hour).UTC()}},
|
||||||
|
} {
|
||||||
|
var plan []struct{ Detail string }
|
||||||
|
require.NoError(t, DB.Raw(test.query, test.args...).Scan(&plan).Error)
|
||||||
|
require.NotEmpty(t, plan)
|
||||||
|
require.Contains(t, plan[0].Detail, test.index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistorySweepKeepsReportReceivedDuringObservation(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
start := time.Now().Add(-time.Hour)
|
||||||
|
r.RecordOnline(1, start)
|
||||||
|
id := r.open[1].id
|
||||||
|
r.SweepStale(time.Now(), func(uint64) (time.Time, bool) {
|
||||||
|
r.RecordOnline(1, time.Now())
|
||||||
|
return start, true
|
||||||
|
})
|
||||||
|
require.Contains(t, r.open, uint64(1))
|
||||||
|
// A report after an hour starts a new session; the stale sweep must not close it.
|
||||||
|
require.NotEqual(t, id, r.open[1].id)
|
||||||
|
require.False(t, getOnlineSession(t, id).Ongoing)
|
||||||
|
require.True(t, getOnlineSession(t, r.open[1].id).Ongoing)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryStartupReadFailureBlocksQueryAndSweepRetries(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
start := time.Now().Add(-time.Hour)
|
||||||
|
old := model.OnlineSession{ServerID: 1, StartedAt: start, LastActiveAt: start, Ongoing: true}
|
||||||
|
require.NoError(t, DB.Create(&old).Error)
|
||||||
|
require.NoError(t, DB.Callback().Query().Before("gorm:query").Register("reject_history_restore", func(db *gorm.DB) { db.AddError(errors.New("restore unavailable")) }))
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
_, err := r.Sessions(1, start, time.Now())
|
||||||
|
require.ErrorContains(t, err, "recovery unavailable")
|
||||||
|
r.RecordOnline(1, time.Now())
|
||||||
|
require.Empty(t, r.open)
|
||||||
|
require.NoError(t, DB.Callback().Query().Remove("reject_history_restore"))
|
||||||
|
r.SweepStale(time.Now(), func(uint64) (time.Time, bool) { return time.Time{}, false })
|
||||||
|
require.False(t, getOnlineSession(t, old.ID).Ongoing)
|
||||||
|
require.Equal(t, int64(0), countOnlineSessions(t, "ongoing = ?", true))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryDisconnectRetriesStartupRead(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
start := time.Now().Add(-10 * time.Second)
|
||||||
|
old := model.OnlineSession{ServerID: 1, StartedAt: start, LastActiveAt: start, Ongoing: true}
|
||||||
|
require.NoError(t, DB.Create(&old).Error)
|
||||||
|
require.NoError(t, DB.Callback().Query().Before("gorm:query").Register("reject_history_restore", func(db *gorm.DB) { db.AddError(errors.New("restore unavailable")) }))
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
require.NoError(t, DB.Callback().Query().Remove("reject_history_restore"))
|
||||||
|
end := start.Add(time.Second)
|
||||||
|
r.RecordOffline(1, end)
|
||||||
|
require.False(t, getOnlineSession(t, old.ID).Ongoing)
|
||||||
|
require.True(t, getOnlineSession(t, old.ID).EndedAt.Equal(end))
|
||||||
|
r.RecordOnline(1, time.Now())
|
||||||
|
require.NotEqual(t, old.ID, r.open[1].id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryReportAfterTimeoutSplitsBeforeSweep(t *testing.T) {
|
||||||
|
for _, failClose := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "persisted", true: "retry_close"}[failClose], func(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
start := time.Now().UTC().Truncate(time.Second)
|
||||||
|
r.RecordOnline(1, start)
|
||||||
|
r.RecordOnline(1, start.Add(time.Second))
|
||||||
|
first := r.open[1].id
|
||||||
|
r.SweepStale(start.Add(30*time.Second), func(uint64) (time.Time, bool) { return start.Add(time.Second), true })
|
||||||
|
require.Equal(t, first, r.open[1].id)
|
||||||
|
if failClose {
|
||||||
|
injectCloseUpdateFailure(t)
|
||||||
|
}
|
||||||
|
r.RecordOnline(1, start.Add(45*time.Second))
|
||||||
|
require.NotEqual(t, first, r.open[1].id)
|
||||||
|
second := r.open[1].id
|
||||||
|
rows, err := r.Sessions(1, start, start.Add(46*time.Second))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 2)
|
||||||
|
require.False(t, rows[0].Ongoing)
|
||||||
|
require.True(t, rows[0].EndedAt.Equal(start.Add(time.Second)))
|
||||||
|
require.True(t, rows[1].StartedAt.Equal(start.Add(45*time.Second)))
|
||||||
|
if failClose {
|
||||||
|
require.Len(t, r.closing[1], 1)
|
||||||
|
dropCloseUpdateFailure(t)
|
||||||
|
}
|
||||||
|
r.SweepStale(start.Add(46*time.Second), func(uint64) (time.Time, bool) { return start.Add(45 * time.Second), true })
|
||||||
|
require.Empty(t, r.closing)
|
||||||
|
require.False(t, getOnlineSession(t, first).Ongoing)
|
||||||
|
require.True(t, getOnlineSession(t, first).EndedAt.Equal(start.Add(time.Second)))
|
||||||
|
require.True(t, getOnlineSession(t, second).Ongoing)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlineHistoryDisconnectSurvivesUnavailableRecovery(t *testing.T) {
|
||||||
|
for _, failClose := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "close_success", true: "close_retry"}[failClose], func(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
start := time.Now().UTC().Add(-10 * time.Second)
|
||||||
|
old := model.OnlineSession{ServerID: 1, StartedAt: start, LastActiveAt: start, Ongoing: true}
|
||||||
|
require.NoError(t, DB.Create(&old).Error)
|
||||||
|
require.NoError(t, DB.Callback().Query().Before("gorm:query").Register("unavailable_disconnect_restore", func(db *gorm.DB) { db.AddError(errors.New("restore unavailable")) }))
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
end := start.Add(time.Second)
|
||||||
|
r.RecordOffline(1, end)
|
||||||
|
// Later disconnects while recovery is unavailable must not extend the first session.
|
||||||
|
r.RecordOffline(1, end.Add(time.Second))
|
||||||
|
require.NoError(t, DB.Callback().Query().Remove("unavailable_disconnect_restore"))
|
||||||
|
if failClose {
|
||||||
|
injectCloseUpdateFailure(t)
|
||||||
|
}
|
||||||
|
reconnect := start.Add(5 * time.Second)
|
||||||
|
r.RecordOnline(1, reconnect)
|
||||||
|
require.NotEqual(t, old.ID, r.open[1].id)
|
||||||
|
rows, err := r.Sessions(1, start, reconnect.Add(time.Second))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 2)
|
||||||
|
require.False(t, rows[0].Ongoing)
|
||||||
|
require.True(t, rows[0].EndedAt.Equal(end))
|
||||||
|
require.True(t, rows[1].StartedAt.Equal(reconnect))
|
||||||
|
if failClose {
|
||||||
|
require.Len(t, r.closing[1], 1)
|
||||||
|
dropCloseUpdateFailure(t)
|
||||||
|
}
|
||||||
|
r.SweepStale(reconnect.Add(time.Second), func(uint64) (time.Time, bool) { return reconnect, true })
|
||||||
|
require.Empty(t, r.closing)
|
||||||
|
require.False(t, getOnlineSession(t, old.ID).Ongoing)
|
||||||
|
require.True(t, getOnlineSession(t, old.ID).EndedAt.Equal(end))
|
||||||
|
require.True(t, getOnlineSession(t, r.open[1].id).Ongoing)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package singleton
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/nezhahq/nezha/model"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRestoreOngoingSessionsReconcilesRecentDuplicates(t *testing.T) {
|
||||||
|
for _, failClose := range []bool{false, true} {
|
||||||
|
name := "successful_close"
|
||||||
|
if failClose {
|
||||||
|
name = "retry_failed_close"
|
||||||
|
}
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
base := time.Now().Add(-10 * time.Second).Truncate(time.Second)
|
||||||
|
// Insert out of chronological order: choosing by ID alone would keep the wrong session.
|
||||||
|
newest := model.OnlineSession{ServerID: 1, StartedAt: base.Add(4 * time.Second), LastActiveAt: base.Add(5 * time.Second), Ongoing: true}
|
||||||
|
oldest := model.OnlineSession{ServerID: 1, StartedAt: base, LastActiveAt: base.Add(time.Second), Ongoing: true}
|
||||||
|
middle := model.OnlineSession{ServerID: 1, StartedAt: base.Add(2 * time.Second), LastActiveAt: base.Add(3 * time.Second), Ongoing: true}
|
||||||
|
for _, session := range []*model.OnlineSession{&newest, &oldest, &middle} {
|
||||||
|
require.NoError(t, DB.Create(session).Error)
|
||||||
|
}
|
||||||
|
if failClose {
|
||||||
|
injectCloseUpdateFailure(t)
|
||||||
|
}
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
require.Len(t, r.open, 1)
|
||||||
|
require.Equal(t, newest.ID, r.open[1].id)
|
||||||
|
if failClose {
|
||||||
|
require.Len(t, r.closing[1], 2)
|
||||||
|
require.Equal(t, int64(3), countOnlineSessions(t, "ongoing = ?", true))
|
||||||
|
dropCloseUpdateFailure(t)
|
||||||
|
}
|
||||||
|
r.SweepStale(base.Add(6*time.Second), func(uint64) (time.Time, bool) { return newest.LastActiveAt, true })
|
||||||
|
require.Empty(t, r.closing)
|
||||||
|
require.True(t, getOnlineSession(t, newest.ID).Ongoing)
|
||||||
|
for _, previous := range []model.OnlineSession{oldest, middle} {
|
||||||
|
stored := getOnlineSession(t, previous.ID)
|
||||||
|
require.False(t, stored.Ongoing)
|
||||||
|
require.True(t, stored.EndedAt.Equal(previous.LastActiveAt))
|
||||||
|
}
|
||||||
|
r.RecordOnline(1, base.Add(7*time.Second))
|
||||||
|
require.Equal(t, newest.ID, r.open[1].id)
|
||||||
|
require.Equal(t, int64(3), countOnlineSessions(t, "server_id = ?", 1))
|
||||||
|
r.SweepStale(base.Add(2*model.OnlineActiveWindow), func(uint64) (time.Time, bool) { return time.Time{}, false })
|
||||||
|
require.Empty(t, r.open)
|
||||||
|
require.Empty(t, r.closing)
|
||||||
|
require.Equal(t, int64(0), countOnlineSessions(t, "ongoing = ?", true))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreOngoingSessionsUsesIDToBreakStartTimeTies(t *testing.T) {
|
||||||
|
setupOnlineHistoryTestDB(t)
|
||||||
|
start := time.Now().Add(-5 * time.Second)
|
||||||
|
first := model.OnlineSession{ServerID: 1, StartedAt: start, LastActiveAt: start, Ongoing: true}
|
||||||
|
second := first
|
||||||
|
other := model.OnlineSession{ServerID: 2, StartedAt: start, LastActiveAt: start, Ongoing: true}
|
||||||
|
for _, session := range []*model.OnlineSession{&first, &second, &other} {
|
||||||
|
require.NoError(t, DB.Create(session).Error)
|
||||||
|
}
|
||||||
|
r := NewOnlineHistoryRecorder()
|
||||||
|
require.Len(t, r.open, 2)
|
||||||
|
require.Equal(t, second.ID, r.open[1].id)
|
||||||
|
require.Equal(t, other.ID, r.open[2].id)
|
||||||
|
require.False(t, getOnlineSession(t, first.ID).Ongoing)
|
||||||
|
require.True(t, getOnlineSession(t, first.ID).EndedAt.Equal(start))
|
||||||
|
require.Equal(t, int64(2), countOnlineSessions(t, "ongoing = ?", true))
|
||||||
|
}
|
||||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user