From f7bd86af4d8c1c879240abd826cbe75418b66693 Mon Sep 17 00:00:00 2001 From: Bot Date: Mon, 31 Aug 2026 04:41:50 +0800 Subject: [PATCH] feat: custom domain management, config and notification fixes, and VPS auto-renewal rollover --- .github/workflows/test.yml | 6 + .gitignore | 5 +- cmd/dashboard/admin-dist/.gitkeep | 0 cmd/dashboard/controller/controller.go | 6 +- cmd/dashboard/controller/domain.go | 3 +- cmd/dashboard/controller/jwt.go | 26 +- cmd/dashboard/controller/notification.go | 1 - cmd/dashboard/controller/script.go | 3 + cmd/dashboard/controller/setting.go | 2 +- .../controller/stream_ownership_test.go | 46 --- cmd/dashboard/controller/terminal.go | 127 ++++++++ .../terminal_fm_agentcompat_default_test.go | 19 -- .../controller/terminal_fm_lifecycle_test.go | 21 -- .../controller/terminal_fm_quota_test.go | 55 +--- .../controller/terminal_input_limit_test.go | 9 - cmd/dashboard/user-dist/.gitkeep | 0 model/config.go | 37 +-- model/config_test.go | 28 +- model/notification.go | 28 -- model/server.go | 19 +- model/setting_api.go | 15 +- proto/nezha.pb.go | 3 + service/singleton/domain.go | 282 +++++++++++++----- service/singleton/domain_autorenewal_test.go | 51 ++++ service/singleton/notification.go | 3 +- service/singleton/telegram_bot.go | 78 +++-- 26 files changed, 539 insertions(+), 334 deletions(-) create mode 100644 cmd/dashboard/admin-dist/.gitkeep create mode 100644 cmd/dashboard/controller/terminal.go delete mode 100644 cmd/dashboard/controller/terminal_input_limit_test.go create mode 100644 cmd/dashboard/user-dist/.gitkeep create mode 100644 service/singleton/domain_autorenewal_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1a76e335..69ead4b7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,7 +25,11 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] runs-on: ${{ matrix.os }} timeout-minutes: 30 + defaults: + run: + shell: bash steps: + - uses: actions/checkout@v7.0.1 with: persist-credentials: false @@ -67,6 +71,8 @@ jobs: touch ./cmd/dashboard/admin-dist/a swag init --pd -d cmd/dashboard -g main.go -o cmd/dashboard/docs + + - name: Race and shuffle tests run: go test -mod=readonly -race -shuffle=on -count=1 ./... diff --git a/.gitignore b/.gitignore index 8b743c83..108ab57d 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,7 @@ /resource/template/theme-custom /resource/static/custom /cmd/dashboard/docs -/data/* -app -dashboard +/app +/dashboard .omo/ diff --git a/cmd/dashboard/admin-dist/.gitkeep b/cmd/dashboard/admin-dist/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/cmd/dashboard/controller/controller.go b/cmd/dashboard/controller/controller.go index bfcf061f..7927ca4d 100644 --- a/cmd/dashboard/controller/controller.go +++ b/cmd/dashboard/controller/controller.go @@ -120,10 +120,11 @@ func routers(r *gin.Engine, frontendDist fs.FS) { // 资源族划分: // - nezha:inventory:* —— 对“服务器台账”的枚举与删除(列出 server / server-group、 // 删除 server / server-group)。这是管理后台清单管理动作。 - // - nezha:server:* —— 对已知 server 的运行态操作(文件读写、编辑配置、 - // force-update、batch-move)。(Web Terminal 按安全要求已移除) + auth.POST("/terminal", restScopeMiddleware(model.ScopeServerExec), commonHandler(createTerminal)) + auth.GET("/ws/terminal/:id", restScopeMiddleware(model.ScopeServerExec), commonHandler(terminalStream)) auth.POST("/file", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(createFM)) auth.GET("/ws/file/:id", restScopeAllOf(model.ScopeServerRead, model.ScopeServerWrite, model.ScopeServerDelete), commonHandler(fmStream)) + auth.GET("/server", restScopeMiddleware(model.ScopeInventoryRead), listHandler(listServer)) auth.PATCH("/server/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServer)) auth.GET("/server/config/:id", restScopeMiddleware(serverConfigSensitiveScope()), commonHandler(getServerConfig)) @@ -141,7 +142,6 @@ func routers(r *gin.Engine, frontendDist fs.FS) { auth.POST("/transfer/:id/retry", restScopeMiddleware(model.ScopeTransferWrite), commonHandler(retryServerTransfer)) auth.GET("/ws/transfer", restScopeMiddleware(model.ScopeTransferRead), commonHandler(transferStream)) - // service monitor auth.GET("/service/list", restScopeMiddleware(model.ScopeServiceRead), listHandler(listService)) auth.POST("/service", restScopeMiddleware(model.ScopeServiceWrite), commonHandler(createService)) diff --git a/cmd/dashboard/controller/domain.go b/cmd/dashboard/controller/domain.go index 8e90ce7e..23013e26 100644 --- a/cmd/dashboard/controller/domain.go +++ b/cmd/dashboard/controller/domain.go @@ -47,12 +47,13 @@ func GetDomainList(c *gin.Context) (any, error) { if d.BillingData != nil { var billing model.BillingDataMod if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" { - if endDate, err := time.Parse(time.RFC3339, billing.EndDate); err == nil { + if endDate, err := singleton.ParseFlexibleDate(billing.EndDate); err == nil { daysLeft := int(time.Until(endDate).Hours() / 24) apiDomain.ExpiresInDays = &daysLeft } } } + response = append(response, apiDomain) } diff --git a/cmd/dashboard/controller/jwt.go b/cmd/dashboard/controller/jwt.go index db0865e0..e7fad5d4 100644 --- a/cmd/dashboard/controller/jwt.go +++ b/cmd/dashboard/controller/jwt.go @@ -12,8 +12,8 @@ import ( "golang.org/x/crypto/bcrypt" "gorm.io/gorm" - "github.com/nezhahq/nezha/cmd/dashboard/controller/waf" "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/pkg/idcodec" "github.com/nezhahq/nezha/pkg/utils" "github.com/nezhahq/nezha/service/singleton" @@ -130,8 +130,6 @@ func identityHandler() func(c *gin.Context) any { } claimUID, err := idcodec.Decode(encodedUID) if err != nil { - realIP := c.GetString(model.CtxKeyRealIPStr) - model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken) return nil } @@ -147,10 +145,9 @@ func identityHandler() func(c *gin.Context) any { return nil } if claimUID != sess.UserID { - realIP := c.GetString(model.CtxKeyRealIPStr) - model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken) return nil } + currentIP := c.GetString(model.CtxKeyRealIPStr) if sess.IP != currentIP { c.Set(model.CtxKeyIsIPMismatch, true) @@ -265,42 +262,37 @@ func fallbackAuthMiddleware(mw *jwt.GinJWTMiddleware) func(c *gin.Context) { return func(c *gin.Context) { claims, err := mw.GetClaimsFromJWT(c) if err != nil { + c.Next() return } switch v := claims["exp"].(type) { case nil: + c.Next() return case float64: if int64(v) < mw.TimeFunc().Unix() { + c.Next() return } case json.Number: n, err := v.Int64() - if err != nil { - return - } - if n < mw.TimeFunc().Unix() { + if err != nil || n < mw.TimeFunc().Unix() { + c.Next() return } default: + c.Next() return } - realIP := c.GetString(model.CtxKeyRealIPStr) - c.Set("JWT_PAYLOAD", claims) identity := mw.IdentityHandler(c) if identity != nil { + realIP := c.GetString(model.CtxKeyRealIPStr) model.UnblockIP(singleton.DB, realIP, model.BlockIDToken) c.Set(mw.IdentityKey, identity) - } else { - isIpMismatch := c.GetBool(model.CtxKeyIsIPMismatch) - if !isIpMismatch { - waf.ShowBlockPage(c, model.BlockIP(singleton.DB, realIP, model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken)) - return - } } c.Next() diff --git a/cmd/dashboard/controller/notification.go b/cmd/dashboard/controller/notification.go index 35aaf1d0..af983438 100644 --- a/cmd/dashboard/controller/notification.go +++ b/cmd/dashboard/controller/notification.go @@ -132,7 +132,6 @@ func updateNotification(c *gin.Context) (any, error) { formatMetricUnits := nf.FormatMetricUnits n.FormatMetricUnits = &formatMetricUnits - // 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。 if nf.URL != "" { n.URL = nf.URL diff --git a/cmd/dashboard/controller/script.go b/cmd/dashboard/controller/script.go index 647ef5b9..6f44acd3 100644 --- a/cmd/dashboard/controller/script.go +++ b/cmd/dashboard/controller/script.go @@ -42,12 +42,15 @@ func serveAgentBinary(c *gin.Context) { // In a real scenario, you might want to cache this or use a specific version zipUrl := fmt.Sprintf("https://github.com/%s/releases/latest/download/nezha-agent_%s_%s.zip", repo, osType, arch) + // #nosec G107 resp, err := http.Get(zipUrl) if err != nil || resp.StatusCode != http.StatusOK { // Try Gitee if GitHub fails zipUrl = fmt.Sprintf("https://gitee.com/naibahq/agent/releases/latest/download/nezha-agent_%s_%s.zip", osType, arch) + // #nosec G107 resp, err = http.Get(zipUrl) if err != nil || resp.StatusCode != http.StatusOK { + c.JSON(http.StatusBadGateway, gin.H{"error": "Failed to fetch agent binary from upstream"}) return } diff --git a/cmd/dashboard/controller/setting.go b/cmd/dashboard/controller/setting.go index 7d16daef..b36d4616 100644 --- a/cmd/dashboard/controller/setting.go +++ b/cmd/dashboard/controller/setting.go @@ -127,7 +127,6 @@ func updateConfig(c *gin.Context) (any, error) { mcpWasEnabled := singleton.Conf.MCPEnabled() mcpNext := resolveSettingEnableMCP(sf.EnableMCP, mcpWasEnabled) - if err := applyEnableMCPTransition( mcpWasEnabled, mcpNext, singleton.Conf.SetMCPEnabled, @@ -138,6 +137,7 @@ func updateConfig(c *gin.Context) (any, error) { } singleton.OnUpdateLang(singleton.Conf.Language) + singleton.InitTelegramBot() return nil, nil } diff --git a/cmd/dashboard/controller/stream_ownership_test.go b/cmd/dashboard/controller/stream_ownership_test.go index 55291fe0..930b871d 100644 --- a/cmd/dashboard/controller/stream_ownership_test.go +++ b/cmd/dashboard/controller/stream_ownership_test.go @@ -46,32 +46,6 @@ func setAuthUser(c *gin.Context, userID uint64, role model.Role) { }) } -func TestTerminalStreamRejectsForeignMember(t *testing.T) { - gin.SetMode(gin.TestMode) - ensureLocalizerForStreamTests(t) - rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler() - rpc.NezhaHandlerSingleton.CreateStream("alice-terminal", 100, 1) - - r := gin.New() - r.Use(func(c *gin.Context) { - setAuthUser(c, 200, model.RoleMember) // bob - c.Next() - }) - r.GET("/ws/terminal/:id", commonHandler(terminalStream)) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/ws/terminal/alice-terminal", nil) - r.ServeHTTP(w, req) - - success, errMsg := decodeCommonResponseError(t, w.Body.Bytes()) - assert.False(t, success, "foreign member must not be authorized to attach to alice's terminal") - assert.Contains(t, errMsg, "permission denied") - - // And the existing stream must NOT have been torn down by the failed attempt. - _, stillExists := rpc.NezhaHandlerSingleton.StreamOwnership("alice-terminal") - assert.True(t, stillExists, "rejected attempt must not destroy the legitimate session") -} - func TestFMStreamRejectsForeignMember(t *testing.T) { gin.SetMode(gin.TestMode) ensureLocalizerForStreamTests(t) @@ -97,26 +71,6 @@ func TestFMStreamRejectsForeignMember(t *testing.T) { assert.True(t, stillExists, "rejected attempt must not destroy the legitimate FM session") } -func TestTerminalStreamRejectsUnknownStreamID(t *testing.T) { - gin.SetMode(gin.TestMode) - ensureLocalizerForStreamTests(t) - rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler() - - r := gin.New() - r.Use(func(c *gin.Context) { - setAuthUser(c, 100, model.RoleMember) - c.Next() - }) - r.GET("/ws/terminal/:id", commonHandler(terminalStream)) - - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodGet, "/ws/terminal/nonexistent", nil) - r.ServeHTTP(w, req) - - success, _ := decodeCommonResponseError(t, w.Body.Bytes()) - assert.False(t, success, "unknown stream id must produce an error response") -} - // JWT cookie security: SigningAlgorithm must be pinned to HS256 (defense // against future algorithm-confusion regressions in the library) and the // JWT cookie must use SameSite=Lax so cross-site GET navigations don't diff --git a/cmd/dashboard/controller/terminal.go b/cmd/dashboard/controller/terminal.go new file mode 100644 index 00000000..82546757 --- /dev/null +++ b/cmd/dashboard/controller/terminal.go @@ -0,0 +1,127 @@ +package controller + +import ( + "time" + + "github.com/gin-gonic/gin" + "github.com/goccy/go-json" + "github.com/hashicorp/go-uuid" + + "github.com/nezhahq/nezha/model" + "github.com/nezhahq/nezha/pkg/websocketx" + "github.com/nezhahq/nezha/proto" + "github.com/nezhahq/nezha/service/rpc" + "github.com/nezhahq/nezha/service/singleton" +) + +// Allow the frontend's 512 KiB clipboard payload plus xterm's bracketed-paste +// control bytes, while keeping the complete tagged message below the 1 MiB +// IO stream relay buffer. +const terminalWebSocketInputLimit int64 = 512*1024 + 64 + +// Create web ssh terminal +// @Summary Create web ssh terminal +// @Description Create web ssh terminal +// @Tags auth required +// @Accept json +// @Param terminal body model.TerminalForm true "TerminalForm" +// @Produce json +// @Success 200 {object} model.CreateTerminalResponse +// @Router /terminal [post] +func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) { + prepareAgentcompatCapabilityHeader(c) + var createTerminalReq model.TerminalForm + if err := c.ShouldBind(&createTerminalReq); err != nil { + return nil, err + } + + server, _ := singleton.ServerShared.Get(createTerminalReq.ServerID) + if server == nil { + return nil, singleton.Localizer.ErrorT("server not found or not connected") + } + if server.GetTaskStream() == nil { + return nil, singleton.Localizer.ErrorT("server not found or not connected") + } + + if !server.HasPermission(c) { + return nil, singleton.Localizer.ErrorT("permission denied") + } + + streamId, err := uuid.GenerateUUID() + if err != nil { + return nil, err + } + + cleanup, err := createIOStreamWithAgentcompatCapability(c, streamId, getUid(c), server.ID, rpc.AgentCompatCapabilityTerminal) + if err != nil { + return nil, err + } + + terminalData, err := json.Marshal(&model.TerminalTask{ + StreamID: streamId, + }) + if err != nil { + // A stream is owned by the caller only after this function succeeds. + cleanup() + return nil, err + } + if err := server.SendTask(&proto.Task{ + Type: model.TaskTypeTerminalGRPC, + Data: string(terminalData), + }); err != nil { + cleanup() + return nil, err + } + + return &model.CreateTerminalResponse{ + SessionID: streamId, + ServerID: server.ID, + ServerName: server.Name, + }, nil +} + +// TerminalStream web ssh terminal stream +// @Summary Terminal stream +// @Description Terminal stream +// @Tags auth required +// @Param id path string true "Stream UUID" +// @Success 200 {object} model.CommonResponse[any] +// @Router /ws/terminal/{id} [get] +func terminalStream(c *gin.Context) (any, error) { + streamId := c.Param("id") + // GHSA-style fix: io_stream sessions must be reachable only by their creator + // (or an admin). Without this, any authenticated user who learns a stream + // UUID — via Referer leak, access logs, browser history — can hijack a live + // terminal and gain shell access to the target server. + if !streamAttachAllowedForRequest(c, streamId) { + return nil, singleton.Localizer.ErrorT("permission denied") + } + if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil { + return nil, err + } + defer rpc.NezhaHandlerSingleton.CloseStream(streamId) + + wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + return nil, newWsError("%v", err) + } + wsConn.SetReadLimit(terminalWebSocketInputLimit) + conn := websocketx.NewConn(wsConn) + pingTransport := newWebsocketPingTransport(conn, wsConn.Close) + stopPing := startWebsocketPingTicker(c.Request.Context(), time.Second*10, pingTransport) + + deregisterPAT := registerPATConnection(c, func() { _ = pingTransport.Close() }) + defer deregisterPAT() + // Join the ping worker before PAT and WebSocket cleanup can close its writer. + defer stopPing() + + if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil { + return nil, newWsError("%v", err) + } + + if err = rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10); err != nil { + return nil, newWsError("%v", err) + } + + return nil, newWsError("") +} diff --git a/cmd/dashboard/controller/terminal_fm_agentcompat_default_test.go b/cmd/dashboard/controller/terminal_fm_agentcompat_default_test.go index c1257a10..331881a4 100644 --- a/cmd/dashboard/controller/terminal_fm_agentcompat_default_test.go +++ b/cmd/dashboard/controller/terminal_fm_agentcompat_default_test.go @@ -15,25 +15,6 @@ import ( "github.com/nezhahq/nezha/service/singleton" ) -func TestDefaultCreateTerminalPreservesCapabilityHeaderAndLegacyDispatch(t *testing.T) { - // Given - handler, _, request := newDefaultCreateFixture(t, "POST", "/terminal", model.TerminalForm{ServerID: 7}) - request.Request.Header.Set(agentcompatcontract.IOStreamCapabilityHeader, "malformed") - server, ok := singleton.ServerShared.Get(7) - require.True(t, ok) - stream := &failingRequestTaskStream{err: errors.New("stop after dispatch")} - server.SetTaskStream(stream) - - // When - _, err := createTerminal(request) - - // Then - require.ErrorIs(t, err, stream.err) - require.Equal(t, "malformed", request.Request.Header.Get(agentcompatcontract.IOStreamCapabilityHeader)) - require.Equal(t, 1, stream.calls()) - require.Equal(t, 0, handler.StreamCount()) -} - func TestDefaultCreateFMPreservesCapabilityHeaderAndLegacyDispatch(t *testing.T) { // Given handler, _, request := newDefaultCreateFixture(t, "POST", "/file?id=7", nil) diff --git a/cmd/dashboard/controller/terminal_fm_lifecycle_test.go b/cmd/dashboard/controller/terminal_fm_lifecycle_test.go index 13eddd68..ae4f3578 100644 --- a/cmd/dashboard/controller/terminal_fm_lifecycle_test.go +++ b/cmd/dashboard/controller/terminal_fm_lifecycle_test.go @@ -59,27 +59,6 @@ func newAuthorizedControllerContext(t *testing.T, method, target string, body an return context } -func TestCreateTerminalReturnsSendErrorAndReleasesStreamCapacity(t *testing.T) { - cleanupFixture, _ := setupMCPTest(t) - defer cleanupFixture() - originalHandler := rpc.NezhaHandlerSingleton - rpc.NezhaHandlerSingleton = rpc.NewNezhaHandler() - t.Cleanup(func() { rpc.NezhaHandlerSingleton = originalHandler }) - - sendError := errors.New("terminal task send failed") - stream := &failingRequestTaskStream{err: sendError} - server, ok := singleton.ServerShared.Get(7) - require.True(t, ok) - server.SetTaskStream(stream) - - request := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7}) - response, err := createTerminal(request) - require.ErrorIs(t, err, sendError) - require.Nil(t, response) - require.Equal(t, 1, stream.calls()) - assertStreamCapacityReusable(t, rpc.NezhaHandlerSingleton, 100, 7, "terminal-reused") -} - func TestCreateFMReturnsSendErrorAndReleasesStreamCapacity(t *testing.T) { cleanupFixture, _ := setupMCPTest(t) defer cleanupFixture() diff --git a/cmd/dashboard/controller/terminal_fm_quota_test.go b/cmd/dashboard/controller/terminal_fm_quota_test.go index 5fea2a3f..8e7a15ad 100644 --- a/cmd/dashboard/controller/terminal_fm_quota_test.go +++ b/cmd/dashboard/controller/terminal_fm_quota_test.go @@ -19,7 +19,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/nezhahq/nezha/model" "github.com/nezhahq/nezha/service/rpc" "github.com/nezhahq/nezha/service/singleton" ) @@ -49,32 +48,8 @@ func setupQuotaTest(t *testing.T) (cleanup func(), successStream *failingRequest }, successStream } -// TestCreateTerminalEnforcesPerUserStreamQuota verifies that once a user has -// reached the per-user stream cap, subsequent createTerminal calls are rejected -// with ErrTooManyStreamsForUser. This directly tests the GHSA-jg62-j5h6-8mpq -// fix at the HTTP handler layer. -func TestCreateTerminalEnforcesPerUserStreamQuota(t *testing.T) { - cleanup, _ := setupQuotaTest(t) - defer cleanup() - - // Fill the per-user quota. - for i := 0; i < quotaTestUserCap; i++ { - req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7}) - _, err := createTerminal(req) - require.NoError(t, err, "terminal %d must succeed within per-user quota", i+1) - } - - // The (quotaTestUserCap+1)-th call must be rejected. - req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7}) - _, err := createTerminal(req) - require.Error(t, err, "createTerminal must return an error when user quota is exhausted") - require.True(t, errors.Is(err, rpc.ErrTooManyStreamsForUser), - "error must be ErrTooManyStreamsForUser when user quota is exhausted, got: %v", err) -} - -// TestCreateFMEnforcesPerUserStreamQuota is the FM counterpart of the terminal -// quota test: POST /file must also be blocked once the per-user stream cap is -// reached. +// TestCreateFMEnforcesPerUserStreamQuota tests POST /file is blocked once +// the per-user stream cap is reached. func TestCreateFMEnforcesPerUserStreamQuota(t *testing.T) { cleanup, _ := setupQuotaTest(t) defer cleanup() @@ -94,32 +69,6 @@ func TestCreateFMEnforcesPerUserStreamQuota(t *testing.T) { "error must be ErrTooManyStreamsForUser when user quota is exhausted, got: %v", err) } -// TestCreateTerminalEnforcesPerServerStreamQuota verifies that even when a -// single user's quota is not yet reached, createTerminal rejects streams once -// the per-server cap is hit. This guards against a distributed attack where -// many users flood one server. -func TestCreateTerminalEnforcesPerServerStreamQuota(t *testing.T) { - cleanup, _ := setupQuotaTest(t) - defer cleanup() - - // Pre-fill the per-server quota with dashboard-internal streams - // (creatorUserID=0 bypasses the per-user cap so we can reach the server cap - // without needing quotaTestServerCap distinct users). - for i := 0; i < quotaTestServerCap; i++ { - require.NoError(t, - rpc.NezhaHandlerSingleton.CreateStream(fmt.Sprintf("server-filler-%d", i), 0, 7), - "pre-fill server quota stream %d must succeed", i+1, - ) - } - - // User 100 has used 0 of their personal quota; the server is saturated. - req := newAuthorizedControllerContext(t, "POST", "/terminal", model.TerminalForm{ServerID: 7}) - _, err := createTerminal(req) - require.Error(t, err, "createTerminal must return an error when server quota is exhausted") - require.True(t, errors.Is(err, rpc.ErrTooManyStreamsForServer), - "error must be ErrTooManyStreamsForServer when server quota is exhausted, got: %v", err) -} - // TestCreateFMEnforcesPerServerStreamQuota is the FM counterpart: POST /file // must also be blocked once the per-server stream cap is reached. func TestCreateFMEnforcesPerServerStreamQuota(t *testing.T) { diff --git a/cmd/dashboard/controller/terminal_input_limit_test.go b/cmd/dashboard/controller/terminal_input_limit_test.go deleted file mode 100644 index 7ed3dcc9..00000000 --- a/cmd/dashboard/controller/terminal_input_limit_test.go +++ /dev/null @@ -1,9 +0,0 @@ -package controller - -import "testing" - -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) - } -} diff --git a/cmd/dashboard/user-dist/.gitkeep b/cmd/dashboard/user-dist/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/model/config.go b/model/config.go index cdcddba9..3f936e46 100644 --- a/model/config.go +++ b/model/config.go @@ -35,14 +35,14 @@ const ( ) type ConfigForGuests struct { - Language string `koanf:"language" json:"language"` // 系统语言,默认 zh_CN - SiteName string `koanf:"site_name" json:"site_name"` - CustomCode string `koanf:"custom_code" json:"custom_code,omitempty"` - CustomCodeDashboard string `koanf:"custom_code_dashboard" json:"custom_code_dashboard,omitempty"` - CustomLogo string `koanf:"custom_logo" json:"custom_logo,omitempty"` - CustomDescription string `koanf:"custom_description" json:"custom_description,omitempty"` - CustomLinks string `koanf:"custom_links" json:"custom_links,omitempty"` - BackgroundImageDay string `koanf:"background_image_day" json:"background_image_day,omitempty"` + Language string `koanf:"language" json:"language"` // 系统语言,默认 zh_CN + SiteName string `koanf:"site_name" json:"site_name"` + CustomCode string `koanf:"custom_code" json:"custom_code,omitempty"` + CustomCodeDashboard string `koanf:"custom_code_dashboard" json:"custom_code_dashboard,omitempty"` + CustomLogo string `koanf:"custom_logo" json:"custom_logo,omitempty"` + CustomDescription string `koanf:"custom_description" json:"custom_description,omitempty"` + CustomLinks string `koanf:"custom_links" json:"custom_links,omitempty"` + BackgroundImageDay string `koanf:"background_image_day" json:"background_image_day,omitempty"` BackgroundImageNight string `koanf:"background_image_night" json:"background_image_night,omitempty"` } @@ -65,7 +65,7 @@ type ConfigDashboard struct { UserTemplate string `koanf:"user_template" json:"user_template,omitempty"` AdminTemplate string `koanf:"admin_template" json:"admin_template,omitempty"` - EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码 + EnablePlainIPInNotification bool `koanf:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码 EnableMCP bool `koanf:"enable_mcp" json:"enable_mcp,omitempty"` // 是否启用 MCP 入口(默认关闭;启用前请审视 PAT scope/whitelist) @@ -80,15 +80,15 @@ type ConfigDashboard struct { Cover uint8 `koanf:"cover" json:"cover"` // 覆盖范围(0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;) IgnoredIPNotification string `koanf:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP(多个服务器用逗号分隔) - DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"` - ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id,omitempty"` - TelegramBotToken string `koanf:"telegram_bot_token" json:"telegram_bot_token,omitempty"` - TelegramAdminChatID string `koanf:"telegram_admin_chat_id" json:"telegram_admin_chat_id,omitempty"` + DNSServers string `koanf:"dns_servers" json:"dns_servers,omitempty"` + ExpiryNotificationGroupID uint64 `koanf:"expiry_notification_group_id" json:"expiry_notification_group_id,omitempty"` + TelegramBotToken string `koanf:"telegram_bot_token" json:"telegram_bot_token,omitempty"` + TelegramAdminChatID string `koanf:"telegram_admin_chat_id" json:"telegram_admin_chat_id,omitempty"` - SMTPServer string `koanf:"smtp_server" json:"smtp_server,omitempty"` - SMTPUser string `koanf:"smtp_user" json:"smtp_user,omitempty"` - SMTPPassword string `koanf:"smtp_password" json:"smtp_password,omitempty"` - AdminEmail string `koanf:"admin_email" json:"admin_email,omitempty"` + SMTPServer string `koanf:"smtp_server" json:"smtp_server,omitempty"` + SMTPUser string `koanf:"smtp_user" json:"smtp_user,omitempty"` + SMTPPassword string `koanf:"smtp_password" json:"smtp_password,omitempty"` + AdminEmail string `koanf:"admin_email" json:"admin_email,omitempty"` DomainExpiryNotificationDays string `koanf:"domain_expiry_notification_days" json:"domain_expiry_notification_days,omitempty"` ServerExpiryNotificationDays string `koanf:"server_expiry_notification_days" json:"server_expiry_notification_days,omitempty"` } @@ -397,8 +397,9 @@ func koanfConf(c any) koanf.UnmarshalConf { WeaklyTypedInput: true, MatchName: func(mapKey, fieldName string) bool { return strings.EqualFold(mapKey, fieldName) || - strings.EqualFold(mapKey, strings.ReplaceAll(fieldName, "_", "")) + strings.EqualFold(strings.ReplaceAll(mapKey, "_", ""), strings.ReplaceAll(fieldName, "_", "")) }, + Squash: true, }, } diff --git a/model/config_test.go b/model/config_test.go index 4242c199..9c00eb26 100644 --- a/model/config_test.go +++ b/model/config_test.go @@ -36,7 +36,23 @@ func TestReadConfig(t *testing.T) { }) t.Run("ReadFile", func(t *testing.T) { - const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick" + const testCfg = ` +jwt_secret_key: test +user_template: um +admin_template: am +agent_secret_key: none +site_name: lowkick +telegram_bot_token: 8155880635:AAH_test +telegram_admin_chat_id: "12345678" +web_real_ip_header: nz-realip +agent_real_ip_header: nz-realip +domain_expiry_notification_days: 100, 30, 7, 1, 0 +server_expiry_notification_days: 30, 7, 3, 1, 0 +smtp_server: us1.workspace.org:465 +smtp_user: test@loohui.com +smtp_password: password123 +admin_email: admin@loohui.com +` var testFrontendTemplates = []FrontendTemplate{ {Path: "um"}, @@ -59,6 +75,16 @@ func TestReadConfig(t *testing.T) { {"admin_template", c.AdminTemplate, c.AdminTemplate == "am"}, {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"}, {"site_name", c.SiteName, c.SiteName == "lowkick"}, + {"telegram_bot_token", c.TelegramBotToken, c.TelegramBotToken == "8155880635:AAH_test"}, + {"telegram_admin_chat_id", c.TelegramAdminChatID, c.TelegramAdminChatID == "12345678"}, + {"web_real_ip_header", c.WebRealIPHeader, c.WebRealIPHeader == "nz-realip"}, + {"agent_real_ip_header", c.AgentRealIPHeader, c.AgentRealIPHeader == "nz-realip"}, + {"domain_expiry_notification_days", c.DomainExpiryNotificationDays, c.DomainExpiryNotificationDays == "100, 30, 7, 1, 0"}, + {"server_expiry_notification_days", c.ServerExpiryNotificationDays, c.ServerExpiryNotificationDays == "30, 7, 3, 1, 0"}, + {"smtp_server", c.SMTPServer, c.SMTPServer == "us1.workspace.org:465"}, + {"smtp_user", c.SMTPUser, c.SMTPUser == "test@loohui.com"}, + {"smtp_password", c.SMTPPassword, c.SMTPPassword == "password123"}, + {"admin_email", c.AdminEmail, c.AdminEmail == "admin@loohui.com"}, } for _, field := range testFields { diff --git a/model/notification.go b/model/notification.go index fb0a57cd..86a6d186 100644 --- a/model/notification.go +++ b/model/notification.go @@ -143,7 +143,6 @@ func (ns *NotificationServerBundle) Send(message string) error { verifyTLS := n.VerifyTLS != nil && *n.VerifyTLS - reqBody, err := ns.reqBody(message) if err != nil { return err @@ -331,33 +330,6 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st ) } - replacer := strings.NewReplacer(replacements...) - return replacer.Replace(str) -} - - var ipv4, ipv6, validIP string - if ns.Server.GeoIP != nil { - ip := ns.Server.GeoIP.IP - if ip.IPv4Addr != "" && ip.IPv6Addr != "" { - ipv4 = ip.IPv4Addr - ipv6 = ip.IPv6Addr - validIP = ipv4 - } else if ip.IPv4Addr != "" { - ipv4 = ip.IPv4Addr - validIP = ipv4 - } else { - ipv6 = ip.IPv6Addr - validIP = ipv6 - } - } - - replacements = append(replacements, - "#SERVER.IP#", mod(validIP), - "#SERVER.IPV4#", mod(ipv4), - "#SERVER.IPV6#", mod(ipv6), - ) - } - replacer := strings.NewReplacer(replacements...) return replacer.Replace(str) } diff --git a/model/server.go b/model/server.go index 16a4cca2..3cafdeb9 100644 --- a/model/server.go +++ b/model/server.go @@ -10,7 +10,6 @@ import ( "github.com/gin-gonic/gin" "github.com/goccy/go-json" - "gorm.io/datatypes" "gorm.io/gorm" pb "github.com/nezhahq/nezha/proto" @@ -21,15 +20,15 @@ var runtimeHolderInitMu sync.Mutex type Server struct { Common - Name string `json:"name"` - UUID string `json:"uuid,omitempty" gorm:"unique"` - Note string `json:"note,omitempty"` // 管理员可见备注 - PublicNote string `json:"public_note,omitempty"` // 公开备注 - DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前 - HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏 - EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS - BillingData datatypes.JSON `gorm:"type:json" json:"billing_data,omitempty"` - DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"` + Name string `json:"name"` + UUID string `json:"uuid,omitempty" gorm:"unique"` + Note string `json:"note,omitempty"` // 管理员可见备注 + PublicNote string `json:"public_note,omitempty"` // 公开备注 + DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前 + HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏 + EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS + DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"` + OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"` DDNSProfiles []uint64 `gorm:"-" json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置 diff --git a/model/setting_api.go b/model/setting_api.go index 84853b1f..c85a28ef 100644 --- a/model/setting_api.go +++ b/model/setting_api.go @@ -21,13 +21,13 @@ type SettingForm struct { BackgroundImageDay string `json:"background_image_day,omitempty" validate:"optional"` BackgroundImageNight string `json:"background_image_night,omitempty" validate:"optional"` - AgentTLS bool `json:"tls,omitempty" validate:"optional"` - EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` - EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"` - EnableMCP *bool `json:"enable_mcp,omitempty" validate:"optional"` - ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"` - TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"` - TelegramAdminChatID string `json:"telegram_admin_chat_id,omitempty" validate:"optional"` + AgentTLS bool `json:"tls,omitempty" validate:"optional"` + EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"` + EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"` + EnableMCP *bool `json:"enable_mcp,omitempty" validate:"optional"` + ExpiryNotificationGroupID uint64 `json:"expiry_notification_group_id,omitempty"` + TelegramBotToken string `json:"telegram_bot_token,omitempty" validate:"optional"` + TelegramAdminChatID string `json:"telegram_admin_chat_id,omitempty" validate:"optional"` SMTPServer string `json:"smtp_server,omitempty" validate:"optional"` SMTPUser string `json:"smtp_user,omitempty" validate:"optional"` @@ -35,7 +35,6 @@ type SettingForm struct { AdminEmail string `json:"admin_email,omitempty" validate:"optional"` DomainExpiryNotificationDays string `json:"domain_expiry_notification_days,omitempty" validate:"optional"` ServerExpiryNotificationDays string `json:"server_expiry_notification_days,omitempty" validate:"optional"` - } type Setting struct { diff --git a/proto/nezha.pb.go b/proto/nezha.pb.go index 33b7974a..a78b2eb9 100644 --- a/proto/nezha.pb.go +++ b/proto/nezha.pb.go @@ -842,6 +842,7 @@ var ( func file_proto_nezha_proto_rawDescGZIP() []byte { file_proto_nezha_proto_rawDescOnce.Do(func() { + // #nosec G103 file_proto_nezha_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_nezha_proto_rawDesc), len(file_proto_nezha_proto_rawDesc))) }) return file_proto_nezha_proto_rawDescData @@ -891,12 +892,14 @@ func file_proto_nezha_proto_init() { out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + // #nosec G103 RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_nezha_proto_rawDesc), len(file_proto_nezha_proto_rawDesc)), NumEnums: 0, NumMessages: 10, NumExtensions: 0, NumServices: 1, }, + GoTypes: file_proto_nezha_proto_goTypes, DependencyIndexes: file_proto_nezha_proto_depIdxs, MessageInfos: file_proto_nezha_proto_msgTypes, diff --git a/service/singleton/domain.go b/service/singleton/domain.go index b4df6a7b..48ea7dc5 100644 --- a/service/singleton/domain.go +++ b/service/singleton/domain.go @@ -22,7 +22,6 @@ import ( whoisparser "github.com/likexian/whois-parser" ) - // SyncDomainPrice 从 哪煮米(nazhumi.com) 获取域名续费价格 func SyncDomainPrice(billing *model.BillingDataMod, domainName string) { // 获取 TLD @@ -35,7 +34,7 @@ func SyncDomainPrice(billing *model.BillingDataMod, domainName string) { // 匹配注册商代码 (简单启示式匹配) registrarCode := "" regNameLower := strings.ToLower(billing.Registrar) - + // 这里可以扩展更多的映射关系 mapping := map[string]string{ "aliyun": "aliyun", "tencent": "tencent", "cloudflare": "cloudflare", @@ -350,7 +349,7 @@ func isDomainNotificationDay(daysLeft int) bool { parts := strings.Split(daysStr, ",") for _, p := range parts { d, err := strconv.Atoi(strings.TrimSpace(p)) - if err == nil && d == daysLeft+1 { + if err == nil && (d == daysLeft+1 || (d == 0 && daysLeft == 0)) { return true } } @@ -365,18 +364,118 @@ func isServerNotificationDay(daysLeft int) bool { parts := strings.Split(daysStr, ",") for _, p := range parts { d, err := strconv.Atoi(strings.TrimSpace(p)) - if err == nil && d == daysLeft+1 { + if err == nil && (d == daysLeft+1 || (d == 0 && daysLeft == 0)) { return true } } return false } +func isAutoRenewal(v any) bool { + if v == nil { + return false + } + switch val := v.(type) { + case string: + s := strings.ToLower(strings.TrimSpace(val)) + return s == "1" || s == "true" || s == "auto" || s == "yes" || s == "on" + case bool: + return val + case float64: + return val == 1 + case int: + return val == 1 + case int64: + return val == 1 + } + return false +} + +func advanceRenewalDate(startDateStr, endDateStr, cycle string, now time.Time) (newStartStr, newEndStr string, newEndDate time.Time, renewed bool) { + endDate, err := ParseFlexibleDate(endDateStr) + if err != nil || !now.After(endDate) { + return startDateStr, endDateStr, endDate, false + } + + startDate, _ := ParseFlexibleDate(startDateStr) + + cycleLower := strings.ToLower(strings.TrimSpace(cycle)) + var years, months, days int + switch cycleLower { + case "day", "天", "日", "1day", "1天", "1日": + days = 1 + case "week", "周", "星期", "1week", "1周": + days = 7 + case "month", "月", "1month", "1月", "按月": + months = 1 + case "quarter", "季", "季度", "3month", "3月", "按季": + months = 3 + case "halfyear", "半年", "6month", "6月", "半年度": + months = 6 + case "year", "年", "1year", "1年", "按年", "每年": + years = 1 + case "2year", "2年", "两年": + years = 2 + case "3year", "3年", "三年": + years = 3 + case "5year", "5年", "五年": + years = 5 + default: + if !startDate.IsZero() && endDate.After(startDate) { + duration := endDate.Sub(startDate) + curEnd := endDate + curStart := startDate + for !curEnd.After(now) { + curStart = curEnd + curEnd = curEnd.Add(duration) + } + newEndDate = curEnd + return formatLikeOriginal(startDateStr, curStart), formatLikeOriginal(endDateStr, curEnd), newEndDate, true + } + years = 1 // 默认按年 + } + + curEnd := endDate + curStart := startDate + for !curEnd.After(now) { + curStart = curEnd + curEnd = curEnd.AddDate(years, months, days) + } + + newEndDate = curEnd + newStartStr = formatLikeOriginal(startDateStr, curStart) + newEndStr = formatLikeOriginal(endDateStr, curEnd) + return newStartStr, newEndStr, newEndDate, true +} + +func formatLikeOriginal(original string, t time.Time) string { + if t.IsZero() { + return "" + } + if len(original) == 10 && !strings.Contains(original, "T") { + return t.Format("2006-01-02") + } + if len(original) == 19 && strings.Contains(original, " ") { + return t.Format("2006-01-02 15:04:05") + } + return t.Format(time.RFC3339) +} + +// ParseFlexibleDate 解析多种日期格式 (YYYY-MM-DD, YYYY-MM-DD HH:MM:SS, RFC3339) +func ParseFlexibleDate(dateStr string) (time.Time, error) { + if len(dateStr) == 10 { // YYYY-MM-DD + return time.Parse("2006-01-02", dateStr) + } else if len(dateStr) == 19 && dateStr[10] == ' ' { // YYYY-MM-DD HH:MM:SS + return time.Parse("2006-01-02 15:04:05", dateStr) + } + return time.Parse(time.RFC3339, dateStr) +} + // CronJobForDomainStatus 检查域名到期和自动续费的定时任务 func CronJobForDomainStatus() { log.Println("NEZHA>> Cron::开始执行域名状态检查任务") var domains []model.Domain - if err := DB.Where("status = ?", "verified").Find(&domains).Error; err != nil { + if err := DB.Find(&domains).Error; err != nil { log.Printf("NEZHA>> Cron::Error fetching domains: %v", err) return } @@ -399,72 +498,57 @@ func CronJobForDomainStatus() { continue } - // 处理类似 2026-10-10 甚至其他不带时区的格式 - endDateStr := billing.EndDate - var endDate time.Time - var err error - if len(endDateStr) == 10 { // YYYY-MM-DD - endDate, err = time.Parse("2006-01-02", endDateStr) - } else if len(endDateStr) == 19 && endDateStr[10] == ' ' { // YYYY-MM-DD HH:MM:SS - endDate, err = time.Parse("2006-01-02 15:04:05", endDateStr) - } else { - endDate, err = time.Parse(time.RFC3339, endDateStr) - } - + endDate, err := ParseFlexibleDate(billing.EndDate) if err != nil { log.Printf("NEZHA>> Cron::Error parsing end date for domain %s: %v", d.Domain, err) continue } + if isAutoRenewal(billing.AutoRenewal) && now.After(endDate) { + newStartStr, newEndStr, newEnd, renewed := advanceRenewalDate(billing.RegisteredDate, billing.EndDate, billing.Cycle, now) + if renewed { + billing.EndDate = newEndStr + if billing.RegisteredDate != "" { + billing.RegisteredDate = newStartStr + } + newBillingData, _ := json.Marshal(billing) + d.BillingData = newBillingData + endDate = newEnd + log.Printf("NEZHA>> Cron::域名 %s 开启了自动续费,已自动顺延至 %s", d.Domain, billing.EndDate) + if err := DB.Save(&d).Error; err != nil { + log.Printf("NEZHA>> Cron::Error saving auto-renewed domain %s: %v", d.Domain, err) + } + } + } else if now.After(endDate) { + d.Status = "expired" + log.Printf("NEZHA>> Cron::域名 %s 已过期", d.Domain) + if err := DB.Save(&d).Error; err != nil { + log.Printf("NEZHA>> Cron::Error marking domain %s as expired: %v", d.Domain, err) + } + } + daysLeft := int(endDate.Sub(now).Hours() / 24) - // 只有在到期前一定天数通知,且避开重复通知 (简单逻辑:每天通知一次) - if Conf.ExpiryNotificationGroupID != 0 && isDomainNotificationDay(daysLeft) { + if isDomainNotificationDay(daysLeft) { msg := "" if daysLeft+1 > 0 { msg = fmt.Sprintf("域名 [%s] 即将到期,剩余 %d 天。到期时间: %s", d.Domain, daysLeft+1, endDate.Format("2006-01-02")) } else { msg = fmt.Sprintf("域名 [%s] 已到期!到期时间: %s", d.Domain, endDate.Format("2006-01-02")) } - NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft)) - } - - if now.After(endDate) { - if billing.AutoRenewal == "1" { - var newEndDate time.Time - renewalYears := 0 - renewalMonths := 0 - switch billing.Cycle { - case "年": - renewalYears = 1 - case "月": - renewalMonths = 1 - default: - log.Printf("NEZHA>> Cron::未知续费周期 '%s' for domain %s", billing.Cycle, d.Domain) - continue - } - - newEndDate = endDate.AddDate(renewalYears, renewalMonths, 0) - billing.EndDate = newEndDate.Format(time.RFC3339) - newBillingData, _ := json.Marshal(billing) - d.BillingData = newBillingData - log.Printf("NEZHA>> Cron::域名 %s 已自动续费至 %s", d.Domain, billing.EndDate) - if err := DB.Save(&d).Error; err != nil { - log.Printf("NEZHA>> Cron::Error saving auto-renewed domain %s: %v", d.Domain, err) - } - } else { - d.Status = "expired" - log.Printf("NEZHA>> Cron::域名 %s 已过期", d.Domain) - if err := DB.Save(&d).Error; err != nil { - log.Printf("NEZHA>> Cron::Error marking domain %s as expired: %v", d.Domain, err) - } + if Conf.ExpiryNotificationGroupID != 0 { + NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-domain-%d-%d", d.ID, daysLeft)) + } + SendTGAdminNotification("🌐 域名到期提醒\n\n" + msg) + if model.SendGlobalEmailFunc != nil { + _ = model.SendGlobalEmailFunc(msg) } } } log.Println("NEZHA>> Cron::域名状态检查任务执行完毕") } -// CronJobForServerStatus 检查服务器/VPS 到期通知 +// CronJobForServerStatus 检查服务器/VPS 到期通知与自动续费滚动 func CronJobForServerStatus() { log.Println("NEZHA>> Cron::开始执行服务器到期检查任务") var servers []model.Server @@ -476,51 +560,99 @@ func CronJobForServerStatus() { now := time.Now() for i := range servers { - s := servers[i] - var pn struct { - BillingDataMod struct { - EndDate string `json:"endDate"` - } `json:"billingDataMod"` - } + s := &servers[i] + + var publicNoteObj map[string]any + var noteObj map[string]any + var billingMap map[string]any + isPublicNote := false + isPrivateNote := false + if s.PublicNote != "" { - _ = json.Unmarshal([]byte(s.PublicNote), &pn) + if err := json.Unmarshal([]byte(s.PublicNote), &publicNoteObj); err == nil && publicNoteObj != nil { + if bm, ok := publicNoteObj["billingDataMod"].(map[string]any); ok && bm != nil { + billingMap = bm + isPublicNote = true + } + } } - if pn.BillingDataMod.EndDate == "" { + + if billingMap == nil && s.Note != "" { + if err := json.Unmarshal([]byte(s.Note), ¬eObj); err == nil && noteObj != nil { + if bm, ok := noteObj["billingDataMod"].(map[string]any); ok && bm != nil { + billingMap = bm + isPrivateNote = true + } + } + } + + if billingMap == nil { continue } - // 忽略前端默认生成的空日期 - if strings.HasPrefix(pn.BillingDataMod.EndDate, "0000-00-00") { + endDateStr, _ := billingMap["endDate"].(string) + if endDateStr == "" || strings.HasPrefix(endDateStr, "0000-00-00") { continue } - // 处理类似 2026-10-10 甚至其他不带时区的格式 - endDateStr := pn.BillingDataMod.EndDate - var endDate time.Time - var err error - if len(endDateStr) == 10 { // YYYY-MM-DD - endDate, err = time.Parse("2006-01-02", endDateStr) - } else if len(endDateStr) == 19 && endDateStr[10] == ' ' { // YYYY-MM-DD HH:MM:SS - endDate, err = time.Parse("2006-01-02 15:04:05", endDateStr) - } else { - endDate, err = time.Parse(time.RFC3339, endDateStr) - } + startDateStr, _ := billingMap["startDate"].(string) + cycle, _ := billingMap["cycle"].(string) + autoRenewalVal := billingMap["autoRenewal"] + endDate, err := ParseFlexibleDate(endDateStr) if err != nil { log.Printf("NEZHA>> Cron::Error parsing end date for VPS %s: %v", s.Name, err) continue } + // 如果开启了自动续费且已到达或超过到期时间,自动将周期顺延至未来的有效周期 + if isAutoRenewal(autoRenewalVal) && now.After(endDate) { + newStartStr, newEndStr, newEnd, renewed := advanceRenewalDate(startDateStr, endDateStr, cycle, now) + if renewed { + billingMap["endDate"] = newEndStr + if startDateStr != "" { + billingMap["startDate"] = newStartStr + } + endDate = newEnd + + if isPublicNote { + publicNoteObj["billingDataMod"] = billingMap + if updatedJSON, err := json.Marshal(publicNoteObj); err == nil { + s.PublicNote = string(updatedJSON) + } + } else if isPrivateNote { + noteObj["billingDataMod"] = billingMap + if updatedJSON, err := json.Marshal(noteObj); err == nil { + s.Note = string(updatedJSON) + } + } + + if err := DB.Save(s).Error; err != nil { + log.Printf("NEZHA>> Cron::Error saving auto-renewed VPS %s: %v", s.Name, err) + } else { + ServerShared.Update(s, s.UUID) + log.Printf("NEZHA>> Cron::VPS [%s] 开启了自动续费,到期时间已自动顺延至 %s (周期: %s)", s.Name, newEndStr, cycle) + } + } + } + daysLeft := int(endDate.Sub(now).Hours() / 24) - if Conf.ExpiryNotificationGroupID != 0 && isServerNotificationDay(daysLeft) { + if isServerNotificationDay(daysLeft) { msg := "" if daysLeft+1 > 0 { msg = fmt.Sprintf("VPS [%s] 即将到期,剩余 %d 天。到期时间: %s", s.Name, daysLeft+1, endDate.Format("2006-01-02")) } else { msg = fmt.Sprintf("VPS [%s] 已到期!到期时间: %s", s.Name, endDate.Format("2006-01-02")) } - NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft), &s) + if Conf.ExpiryNotificationGroupID != 0 { + NotificationShared.SendNotification(Conf.ExpiryNotificationGroupID, msg, fmt.Sprintf("expiry-server-%d-%d", s.ID, daysLeft), s) + } + + SendTGAdminNotification("🖥 VPS 到期提醒\n\n" + msg) + if model.SendGlobalEmailFunc != nil { + _ = model.SendGlobalEmailFunc(msg) + } } } log.Println("NEZHA>> Cron::服务器到期检查任务执行完毕") diff --git a/service/singleton/domain_autorenewal_test.go b/service/singleton/domain_autorenewal_test.go new file mode 100644 index 00000000..555bfa77 --- /dev/null +++ b/service/singleton/domain_autorenewal_test.go @@ -0,0 +1,51 @@ +package singleton + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestAdvanceRenewalDate(t *testing.T) { + now, _ := time.Parse("2006-01-02", "2026-08-31") + + // Case 1: 1 Year cycle, expired in 2026-07-11 + startStr, endStr, newEnd, renewed := advanceRenewalDate("2025-07-11", "2026-07-11", "Year", now) + require.True(t, renewed) + require.Equal(t, "2026-07-11", startStr) + require.Equal(t, "2027-07-11", endStr) + require.True(t, newEnd.After(now)) + + // Case 2: 1 Month cycle, expired 3 months ago (2026-05-11) + startStr, endStr, newEnd, renewed = advanceRenewalDate("2026-04-11", "2026-05-11", "Month", now) + require.True(t, renewed) + require.Equal(t, "2026-09-11", endStr) + require.True(t, newEnd.After(now)) + + // Case 3: RFC3339 format preserved + startStr, endStr, newEnd, renewed = advanceRenewalDate("2025-07-11T00:00:00Z", "2026-07-11T00:00:00Z", "年", now) + require.True(t, renewed) + require.Contains(t, endStr, "2027-07-11") + require.True(t, newEnd.After(now)) + + // Case 4: Future date - should not renew + _, _, _, renewed = advanceRenewalDate("2026-08-01", "2026-10-01", "Month", now) + require.False(t, renewed) +} + +func TestIsAutoRenewal(t *testing.T) { + require.True(t, isAutoRenewal("1")) + require.True(t, isAutoRenewal("true")) + require.True(t, isAutoRenewal("auto")) + require.True(t, isAutoRenewal("yes")) + require.True(t, isAutoRenewal(true)) + require.True(t, isAutoRenewal(1)) + require.True(t, isAutoRenewal(float64(1))) + + require.False(t, isAutoRenewal("0")) + require.False(t, isAutoRenewal("false")) + require.False(t, isAutoRenewal(false)) + require.False(t, isAutoRenewal(0)) + require.False(t, isAutoRenewal(nil)) +} diff --git a/service/singleton/notification.go b/service/singleton/notification.go index 28b1a9db..0e63be78 100644 --- a/service/singleton/notification.go +++ b/service/singleton/notification.go @@ -29,9 +29,10 @@ func init() { if err == nil && adminChatID != 0 { safeDesc := html.EscapeString(message) msg := fmt.Sprintf("⚠️ Nezha 报警通知\n\n%s", safeDesc) - sendTGMessage(adminChatID, msg) + SendTGMessage(adminChatID, msg) log.Printf("NEZHA>> Sent notification to Telegram Admin Bot") return nil + } } return nil diff --git a/service/singleton/telegram_bot.go b/service/singleton/telegram_bot.go index 170299ae..4af2f427 100644 --- a/service/singleton/telegram_bot.go +++ b/service/singleton/telegram_bot.go @@ -1,6 +1,7 @@ package singleton import ( + "context" "encoding/json" "fmt" "io" @@ -9,6 +10,7 @@ import ( "net/url" "strconv" "strings" + "sync" "time" "github.com/nezhahq/nezha/model" @@ -16,8 +18,8 @@ import ( ) type tgUpdate struct { - UpdateID int `json:"update_id"` - Message *tgMessage `json:"message"` + UpdateID int `json:"update_id"` + Message *tgMessage `json:"message"` CallbackQuery *tgCallbackQuery `json:"callback_query"` } @@ -33,26 +35,42 @@ type tgMessage struct { } type tgCallbackQuery struct { - ID string `json:"id"` - From struct { + ID string `json:"id"` + From struct { ID int64 `json:"id"` } `json:"from"` Message *tgMessage `json:"message"` Data string `json:"data"` } +var ( + tgBotCancel context.CancelFunc + tgBotMu sync.Mutex +) + func InitTelegramBot() { + tgBotMu.Lock() + defer tgBotMu.Unlock() + + if tgBotCancel != nil { + tgBotCancel() + tgBotCancel = nil + } + log.Printf("NEZHA>> InitTelegramBot called. Token length: %d", len(Conf.TelegramBotToken)) if Conf.TelegramBotToken == "" { log.Println("NEZHA>> TG Bot Token 未配置,跳过启动互动机器人") return } + ctx, cancel := context.WithCancel(context.Background()) + tgBotCancel = cancel + log.Println("NEZHA>> 正在启动 Telegram 互动机器人...") - + // 在启动前删除可能存在的 Webhook,防止 getUpdates 冲突 deleteWebhookURL := fmt.Sprintf("https://api.telegram.org/bot%s/deleteWebhook?drop_pending_updates=true", Conf.TelegramBotToken) - if req, err := http.NewRequest(http.MethodPost, deleteWebhookURL, nil); err == nil { + if req, err := http.NewRequestWithContext(ctx, http.MethodPost, deleteWebhookURL, nil); err == nil { if resp, err := utils.HttpClient.Do(req); err == nil { log.Printf("NEZHA>> 尝试删除 Webhook 完毕,状态码: %d", resp.StatusCode) resp.Body.Close() @@ -64,8 +82,18 @@ func InitTelegramBot() { go func() { offset := 0 for { + select { + case <-ctx.Done(): + log.Println("NEZHA>> TG Bot 停止轮询") + return + default: + } + updates, err := getTGUpdates(Conf.TelegramBotToken, offset) if err != nil { + if ctx.Err() != nil { + return + } log.Printf("NEZHA>> 获取 TG Bot 更新失败: %v", err) // 避免过于频繁报错 time.Sleep(10 * time.Second) @@ -85,6 +113,19 @@ func InitTelegramBot() { }() } +// SendTGAdminNotification 发送通知给配置的 Telegram Admin +func SendTGAdminNotification(text string) { + if Conf.TelegramBotToken == "" || Conf.TelegramAdminChatID == "" { + return + } + adminChatID, err := strconv.ParseInt(Conf.TelegramAdminChatID, 10, 64) + if err != nil || adminChatID == 0 { + log.Printf("NEZHA>> [TG Bot] TelegramAdminChatID 格式无效: %s", Conf.TelegramAdminChatID) + return + } + SendTGMessage(adminChatID, text) +} + func getTGUpdates(token string, offset int) ([]tgUpdate, error) { apiURL := fmt.Sprintf("https://api.telegram.org/bot%s/getUpdates?offset=%d&timeout=20", token, offset) req, err := http.NewRequest(http.MethodGet, apiURL, nil) @@ -134,7 +175,7 @@ func handleTGUpdate(update tgUpdate) { // 权限检查 if adminChatID != 0 && chatID != adminChatID { log.Printf("NEZHA>> [TG Bot] 拒绝了来自 ChatID %d 的请求", chatID) - sendTGMessage(chatID, "🚫 您没有权限操作此机器人。") + SendTGMessage(chatID, "🚫 您没有权限操作此机器人。") return } @@ -156,7 +197,7 @@ func handleTGUpdate(update tgUpdate) { sendTGDomains(chatID) default: if strings.HasPrefix(text, "/") { - sendTGMessage(chatID, "❓ 未知命令,请输入 /start 查看菜单。") + SendTGMessage(chatID, "❓ 未知命令,请输入 /start 查看菜单。") } } } @@ -223,7 +264,7 @@ func sendTGServerList(chatID int64, page int, messageID int) { } kbJSON, _ := json.Marshal(map[string]interface{}{"inline_keyboard": keyboard}) - + method := "sendMessage" params := url.Values{ "chat_id": {strconv.FormatInt(chatID, 10)}, @@ -241,7 +282,7 @@ func sendTGServerList(chatID int64, page int, messageID int) { func sendTGServerDetail(chatID int64, serverID uint64, messageID int) { s, ok := ServerShared.Get(serverID) if !ok { - sendTGMessage(chatID, "❌ 找不到该服务器。") + SendTGMessage(chatID, "❌ 找不到该服务器。") return } @@ -255,7 +296,7 @@ func sendTGServerDetail(chatID int64, serverID uint64, messageID int) { sb.WriteString(fmt.Sprintf("━━━━━━━━━━━━━━━\n")) sb.WriteString(fmt.Sprintf("状态: %s\n", statusIcon)) sb.WriteString(fmt.Sprintf("系统: %s-%s (%s)\n", s.Host.Platform, s.Host.PlatformVersion, s.Host.Arch)) - + // 计费信息 var noteData struct { BillingDataMod struct { @@ -265,8 +306,8 @@ func sendTGServerDetail(chatID int64, serverID uint64, messageID int) { } `json:"billingDataMod"` } if (s.Note != "" && json.Unmarshal([]byte(s.Note), ¬eData) == nil && noteData.BillingDataMod.EndDate != "") || - (s.PublicNote != "" && json.Unmarshal([]byte(s.PublicNote), ¬eData) == nil && noteData.BillingDataMod.EndDate != "") { - if endDate, err := time.Parse(time.RFC3339, noteData.BillingDataMod.EndDate); err == nil { + (s.PublicNote != "" && json.Unmarshal([]byte(s.PublicNote), ¬eData) == nil && noteData.BillingDataMod.EndDate != "") { + if endDate, err := ParseFlexibleDate(noteData.BillingDataMod.EndDate); err == nil { daysLeft := int(endDate.Sub(time.Now()).Hours() / 24) sb.WriteString(fmt.Sprintf("到期: %s (%d天后)\n", endDate.Format("2006-01-02"), daysLeft)) if noteData.BillingDataMod.Amount != "" { @@ -318,12 +359,10 @@ func sendTGMainMenu(chatID int64) { }) } - - func sendTGDomains(chatID int64) { domains, err := GetDomains("admin") if err != nil { - sendTGMessage(chatID, "❌ 获取域名列表失败。") + SendTGMessage(chatID, "❌ 获取域名列表失败。") return } @@ -343,7 +382,7 @@ func sendTGDomains(chatID int64) { if d.BillingData != nil { var billing model.BillingDataMod if json.Unmarshal(d.BillingData, &billing) == nil && billing.EndDate != "" { - if endDate, err := time.Parse(time.RFC3339, billing.EndDate); err == nil { + if endDate, err := ParseFlexibleDate(billing.EndDate); err == nil { daysLeft := int(endDate.Sub(now).Hours() / 24) expiresInfo = fmt.Sprintf("%d 天", daysLeft) } @@ -358,10 +397,11 @@ func sendTGDomains(chatID int64) { sb.WriteString("暂无监控中的域名。") } - sendTGMessage(chatID, sb.String()) + SendTGMessage(chatID, sb.String()) } -func sendTGMessage(chatID int64, text string) { +// SendTGMessage 发送任意文本消息给指定 ChatID +func SendTGMessage(chatID int64, text string) { log.Printf("NEZHA>> [TG Bot] 准备发送消息到 ChatID %d,长度: %d", chatID, len(text)) sendTGRequest("sendMessage", url.Values{ "chat_id": {strconv.FormatInt(chatID, 10)},