Merge upstream monitoring features preserving domain extensions and require fixed Go toolchain

This commit is contained in:
Codex committed 2026-10-22 23:50:03 +08:00
commit 71dd21269b
92 files changed
+8557 -874

No files matched your search

+18
View File
@@ -172,6 +172,15 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
}
if len(r.Rules) > 0 {
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 {
case model.RuleCoverAll, model.RuleCoverIgnoreAll:
default:
@@ -182,10 +191,19 @@ func validateRule(c *gin.Context, r *model.AlertRule) error {
if rule.Duration < 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 {
if !rule.HasSafeCycleUnit() {
return singleton.Localizer.ErrorT("invalid cycle unit")
}
if rule.CycleInterval < 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 {
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))
}
+4
View File
@@ -57,6 +57,9 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
if err := authMiddleware.MiddlewareInit(); err != nil {
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)。
// 不放在 /api/v1 下:MCP client 配置 URL 更短,且 MCP transport 协议演进与 REST API
// 解耦。鉴权一律走 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("/server/:id/service", restScopeMiddleware(model.ScopeServiceRead), commonHandler(listServerServices))
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
// PAT bearer requests bypass — so the only callers gated are
@@ -109,12 +109,14 @@ func TestListDDNS_RedactsCredentials(t *testing.T) {
defer setupTenancyTest(t)()
p := model.DDNSProfile{
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "cloudflare",
AccessID: "id",
AccessSecret: "super-secret-token",
WebhookHeaders: `{"Authorization":"Bearer xxx"}`,
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "cloudflare",
AccessID: "id",
AccessSecret: "super-secret-token",
WebhookURL: "https://ddns.example/update?token=embedded-secret",
WebhookRequestBody: `{"token":"embedded-secret"}`,
WebhookHeaders: `{"Authorization":"Bearer xxx"}`,
}
require.NoError(t, singleton.DB.Create(&p).Error)
singleton.DDNSShared.InsertForTest(&p)
@@ -124,12 +126,18 @@ func TestListDDNS_RedactsCredentials(t *testing.T) {
require.NoError(t, err)
require.Len(t, out, 1)
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.Equal(t, "id", out[0].AccessID, "non-secret fields must be preserved")
var stored model.DDNSProfile
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, "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) {
@@ -164,14 +172,15 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
defer setupTenancyTest(t)()
existing := model.DDNSProfile{
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "webhook",
AccessID: "id",
AccessSecret: "keep-me",
WebhookURL: "http://127.0.0.1/",
WebhookMethod: 1,
WebhookHeaders: `{"X-Token":"keep-header"}`,
Common: model.Common{UserID: 10},
Name: "cf",
Provider: "webhook",
AccessID: "id",
AccessSecret: "keep-me",
WebhookURL: "http://127.0.0.1/",
WebhookRequestBody: `{"X-Token":"keep-body"}`,
WebhookMethod: 1,
WebhookHeaders: `{"X-Token":"keep-header"}`,
}
require.NoError(t, singleton.DB.Create(&existing).Error)
singleton.DDNSShared.InsertForTest(&existing)
@@ -181,10 +190,11 @@ func TestUpdateDDNS_EmptySecretPreservesStored(t *testing.T) {
"provider": "webhook",
"access_id": "id",
"access_secret": "",
"webhook_url": "http://127.0.0.1/",
"webhook_url": "",
"webhook_method": 1,
"webhook_request_type": 1,
"webhook_headers": "",
"webhook_request_body": "",
"max_retries": 3,
})
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, `{"X-Token":"keep-header"}`, after.WebhookHeaders,
"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) {
@@ -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)
}
+8 -2
View File
@@ -34,6 +34,8 @@ func listDDNS(c *gin.Context) ([]*model.DDNSProfile, error) {
// 不影响 singleton 内原始数据。
for _, p := range ddnsProfiles {
p.AccessSecret = ""
p.WebhookURL = ""
p.WebhookRequestBody = ""
p.WebhookHeaders = ""
}
@@ -144,10 +146,8 @@ func updateDDNS(c *gin.Context) (any, error) {
p.Provider = df.Provider
p.Domains = df.Domains
p.AccessID = df.AccessID
p.WebhookURL = df.WebhookURL
p.WebhookMethod = df.WebhookMethod
p.WebhookRequestType = df.WebhookRequestType
p.WebhookRequestBody = df.WebhookRequestBody
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
if df.AccessSecret != "" {
@@ -156,6 +156,12 @@ func updateDDNS(c *gin.Context) (any, error) {
if 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 {
// IDN to ASCII
+14
View File
@@ -15,6 +15,19 @@ import (
"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
// @Summary Create FM session
// @Description Create an "attached" FM. It is advised to only call this within a terminal session.
@@ -103,6 +116,7 @@ func fmStream(c *gin.Context) (any, error) {
if err != nil {
return nil, newWsError("%v", err)
}
limitFileManagerWebSocketInput(wsConn)
conn := websocketx.NewConn(wsConn)
pingTransport := newWebsocketPingTransport(conn, wsConn.Close)
stopPing := startWebsocketPingTicker(c.Request.Context(), time.Second*10, pingTransport)
+1 -1
View File
@@ -149,7 +149,7 @@ func identityHandler() func(c *gin.Context) any {
}
currentIP := c.GetString(model.CtxKeyRealIPStr)
if sess.IP != currentIP {
if !singleton.Conf.JWTIPChangeAllowed() && sess.IP != currentIP {
c.Set(model.CtxKeyIsIPMismatch, true)
return nil
}
@@ -193,6 +193,61 @@ func TestIdentityHandlerFlagsIPMismatch(t *testing.T) {
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) {
cleanup := setupJWTSessionTest(t)
defer cleanup()
+6 -1
View File
@@ -39,7 +39,12 @@ func setupMCPTest(t *testing.T) (func(), uint64) {
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(&model.User{}, &model.APIToken{}, &model.MCPAuditLog{}, &model.Server{}, &model.WAF{}))
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)
user := model.User{Common: model.Common{ID: 100}, Username: "alice", Role: model.RoleMember}
+38 -7
View File
@@ -13,6 +13,7 @@ import (
"io"
"math"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
@@ -91,6 +92,37 @@ var (
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。
// 这是有意设计:transferEntries 本身也只活在内存 sync.Map 里,dashboard
// 重启等价于全部 token 失效;让 secret 也随进程随机,可以避免“secret 来自
@@ -315,6 +347,10 @@ func mintTransferTool(c *gin.Context, serverID uint64, path string, ttlSeconds i
if err := validateTransferPath(path); err != nil {
return nil, err
}
baseURL, err := mcpTransferURLBase(c)
if err != nil {
return nil, err
}
ttl := time.Duration(ttlSeconds) * time.Second
if ttl <= 0 {
ttl = transferTokenTTLDefault
@@ -348,14 +384,9 @@ func mintTransferTool(c *gin.Context, serverID uint64, path string, ttlSeconds i
if err != nil {
return nil, err
}
scheme := "https"
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)
transferURL := fmt.Sprintf("%s/mcp/%s/%s", baseURL, dir, t)
return map[string]any{
"url": url,
"url": transferURL,
"method": map[transferDirection]string{transferDirDownload: "GET", transferDirUpload: "POST"}[dir],
"expires_at": entry.ExpiresAt,
}, 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)
}
+6
View File
@@ -1,6 +1,7 @@
package controller
import (
"net/http"
"slices"
"strconv"
@@ -12,6 +13,8 @@ import (
"github.com/nezhahq/nezha/service/singleton"
)
const notificationBatchDeleteMaxBodyBytes = 1 << 20
// List notification
// @Summary List notification
// @Security BearerAuth
@@ -176,6 +179,9 @@ func updateNotification(c *gin.Context) (any, error) {
// @Router /batch-delete/notification [post]
func batchDeleteNotification(c *gin.Context) (any, error) {
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 {
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)
}
}
+121
View File
@@ -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)
}
+12 -13
View File
@@ -236,10 +236,9 @@ func getServerConfig(c *gin.Context) (string, error) {
}
s, ok := singleton.ServerShared.Get(id)
if !ok {
return "", nil
}
if !s.HasPermission(c) {
// Foreign and unknown IDs deliberately share one response so sequential
// server IDs cannot be enumerated across tenants.
if !ok || !s.HasPermission(c) || !patAllowsServer(c, id) {
return "", singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
@@ -293,16 +292,16 @@ func setServerConfig(c *gin.Context) (*model.ServerTaskResponse, error) {
slist := singleton.ServerShared.GetList()
servers := make([]*model.Server, 0, len(configForm.Servers))
for _, sid := range configForm.Servers {
if s, ok := slist[sid]; ok {
if !s.HasPermission(c) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
resp.Offline = append(resp.Offline, s.ID)
continue
}
servers = append(servers, s)
s, ok := slist[sid]
// 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")
}
if s.GetTaskStream() == nil {
resp.Offline = append(resp.Offline, s.ID)
continue
}
servers = append(servers, s)
}
var wg sync.WaitGroup
@@ -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())
}
+30 -10
View File
@@ -2,6 +2,7 @@ package controller
import (
"errors"
"fmt"
"log"
"strings"
@@ -29,7 +30,7 @@ func listConfig(c *gin.Context) (*model.SettingResponse, error) {
isAdmin = user.Role.IsAdmin()
}
config := *singleton.Conf
config := singleton.Conf.SettingsSnapshot()
config.Language = strings.ReplaceAll(config.Language, "_", "-")
conf := model.SettingResponse{
@@ -92,11 +93,20 @@ func updateConfig(c *gin.Context) (any, error) {
if !userTemplateValid {
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.EnableIPChangeNotification = sf.EnableIPChangeNotification
singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
if sf.AllowJWTIPChange != nil {
singleton.Conf.SetJWTIPChangeAllowed(*sf.AllowJWTIPChange)
}
singleton.Conf.Cover = sf.Cover
singleton.Conf.InstallHost = sf.InstallHost
singleton.Conf.DashboardHost = sf.DashboardHost
@@ -124,18 +134,22 @@ func updateConfig(c *gin.Context) (any, error) {
singleton.Conf.DomainExpiryNotificationDays = sf.DomainExpiryNotificationDays
singleton.Conf.ServerExpiryNotificationDays = sf.ServerExpiryNotificationDays
mcpWasEnabled := singleton.Conf.MCPEnabled()
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
if err := applyEnableMCPTransition(
mcpWasEnabled, mcpNext,
singleton.Conf.SetMCPEnabled,
singleton.Conf.Save,
fireMCPKillSwitch,
); err != nil {
if err := singleton.Conf.CommitOnlineHistoryRetention(sf.OnlineStatsDays, func() error {
mcpWasEnabled := singleton.Conf.MCPEnabled()
mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled)
return applyEnableMCPTransition(
mcpWasEnabled, mcpNext,
singleton.Conf.SetMCPEnabled,
singleton.Conf.Save,
fireMCPKillSwitch,
)
}); err != nil {
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.InitTelegramBot()
return nil, nil
@@ -174,6 +188,12 @@ func fireMCPKillSwitch() {
// the current config to avoid accidentally tripping the kill switch on
// partial PATCH calls that omit enable_mcp.
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 {
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)
}
@@ -0,0 +1,28 @@
package controller
import "testing"
type readLimitRecorder struct {
limit int64
}
func (r *readLimitRecorder) SetReadLimit(limit int64) {
r.limit = limit
}
func TestTerminalWebSocketInputLimitAllowsBoundedPaste(t *testing.T) {
if terminalWebSocketInputLimit != 512*1024+64 {
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)
}
}
+6 -3
View File
@@ -171,6 +171,12 @@ const transferStreamPingInterval = 30 * time.Second
// @Success 200 {object} model.ServerTransfer
// @Router /ws/transfer [get]
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)
if err != nil {
return nil, newWsError("%v", err)
@@ -180,9 +186,6 @@ func transferStream(c *gin.Context) (any, error) {
deregisterPAT := registerPATConnection(c, func() { _ = conn.Close() })
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
// a long idle period followed by a transfer event would race against
// upstream proxy idle-timeouts that may have already closed the conn.