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