feat: custom domain management, config and notification fixes, and VPS auto-renewal rollover

This commit is contained in:
Bot
2026-08-31 05:04:19 +08:00
parent 00f5777112
commit f7bd86af4d
26 changed files with 539 additions and 334 deletions
View File
+3 -3
View File
@@ -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))
+2 -1
View File
@@ -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)
}
+9 -17
View File
@@ -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()
-1
View File
@@ -132,7 +132,6 @@ func updateNotification(c *gin.Context) (any, error) {
formatMetricUnits := nf.FormatMetricUnits
n.FormatMetricUnits = &formatMetricUnits
// 凭据在列表接口已脱敏,前端无法回填;空值视为"不修改",保留旧值避免误清空。
if nf.URL != "" {
n.URL = nf.URL
+3
View File
@@ -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
}
+1 -1
View File
@@ -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
}
@@ -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
+127
View File
@@ -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"
)
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)
@@ -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()
@@ -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) {
@@ -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)
}
}
View File