mirror of
https://github.com/Buriburizaem0n/nezha_domains.git
synced 2026-11-04 08:40:20 +00:00
Merge upstream monitoring features preserving domain extensions and require fixed Go toolchain
This commit is contained in:
commit
71dd21269b
92 files changed
+8557
-874
No files matched your search
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user