feat: enforce telemetry-only mode and safe agent decommission lifecycle

This commit is contained in:
Buri
2026-09-04 18:17:52 +08:00
parent 3d2b27f4ec
commit 8c481985c2
8 changed files with 121 additions and 1 deletions
+1
View File
@@ -132,6 +132,7 @@ func routers(r *gin.Engine, frontendDist fs.FS) {
auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServer)) auth.POST("/batch-delete/server", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServer))
auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer)) auth.POST("/batch-move/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchMoveServer))
auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer)) auth.POST("/force-update/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(forceUpdateServer))
auth.POST("/batch-lockdown/server", restScopeMiddleware(model.ScopeServerWrite), commonHandler(batchLockdownOnlineServers))
auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup)) auth.POST("/server-group", restScopeMiddleware(model.ScopeServerWrite), commonHandler(createServerGroup))
auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup)) auth.PATCH("/server-group/:id", restScopeMiddleware(model.ScopeServerWrite), commonHandler(updateServerGroup))
auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServerGroup)) auth.POST("/batch-delete/server-group", restScopeMiddleware(model.ScopeInventoryDelete), commonHandler(batchDeleteServerGroup))
+4
View File
@@ -44,6 +44,10 @@ func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
return nil, singleton.Localizer.ErrorT("permission denied") return nil, singleton.Localizer.ErrorT("permission denied")
} }
if server.IsTelemetryOnly() {
return nil, singleton.Localizer.ErrorT("file manager is disabled: server is in telemetry-only mode")
}
streamId, err := uuid.GenerateUUID() streamId, err := uuid.GenerateUUID()
if err != nil { if err != nil {
return nil, err return nil, err
+48
View File
@@ -556,3 +556,51 @@ func getServerMetrics(c *gin.Context) (*model.ServerMetricsResponse, error) {
return response, nil return response, nil
} }
// BatchLockdownOnlineServers 批量收敛并锁定现有在线 Agent
// @Summary Batch lockdown online servers to telemetry only
// @Security BearerAuth
// @Produce json
// @Router /batch-lockdown/server [post]
func batchLockdownOnlineServers(c *gin.Context) (*model.ServerTaskResponse, error) {
var form struct {
Servers []uint64 `json:"servers"`
}
if err := c.ShouldBindJSON(&form); err != nil {
return nil, err
}
resp := new(model.ServerTaskResponse)
for _, sid := range form.Servers {
srv, _ := singleton.ServerShared.Get(sid)
if srv == nil || !srv.HasPermission(c) {
resp.Offline = append(resp.Offline, sid)
continue
}
if srv.GetTaskStream() == nil {
resp.Offline = append(resp.Offline, sid)
continue
}
// 下发原子化加固与重启脚本
task := &pb.Task{
Type: model.TaskTypeCommand,
Data: model.SafeDecommissionScript,
}
if err := srv.SendTask(task); err != nil {
if errors.Is(err, model.ErrTaskStreamOffline) {
resp.Offline = append(resp.Offline, sid)
} else {
resp.Failure = append(resp.Failure, sid)
}
continue
}
// 本地更新状态并异步持久化落库
srv.TelemetryOnly = true
singleton.DB.Model(&model.Server{}).Where("id = ?", srv.ID).Update("telemetry_only", true)
resp.Success = append(resp.Success, sid)
}
return resp, nil
}
+4
View File
@@ -47,6 +47,10 @@ func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) {
return nil, singleton.Localizer.ErrorT("permission denied") return nil, singleton.Localizer.ErrorT("permission denied")
} }
if server.IsTelemetryOnly() {
return nil, singleton.Localizer.ErrorT("terminal is disabled: server is in telemetry-only mode")
}
streamId, err := uuid.GenerateUUID() streamId, err := uuid.GenerateUUID()
if err != nil { if err != nil {
return nil, err return nil, err
+38
View File
@@ -38,6 +38,7 @@ type Server struct {
State *HostState `gorm:"-" json:"state,omitempty"` State *HostState `gorm:"-" json:"state,omitempty"`
GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"` GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"`
LastActive time.Time `gorm:"-" json:"last_active,omitempty"` LastActive time.Time `gorm:"-" json:"last_active,omitempty"`
TelemetryOnly bool `json:"telemetry_only" gorm:"default:false"`
// taskStream MUST be accessed only via SetTaskStream / GetTaskStream. Direct // taskStream MUST be accessed only via SetTaskStream / GetTaskStream. Direct
// field access from outside this file races with the gRPC RequestTask // field access from outside this file races with the gRPC RequestTask
@@ -284,7 +285,43 @@ func (s *Server) GetTaskStream() pb.NezhaService_RequestTaskServer {
// The mutex is keyed by holder (= by stream) rather than by *Server so that // The mutex is keyed by holder (= by stream) rather than by *Server so that
// edit/transfer rotations replacing *Server in the singleton map still share // edit/transfer rotations replacing *Server in the singleton map still share
// a single lock across the old and new objects pointing at the same stream. // a single lock across the old and new objects pointing at the same stream.
var ErrControlDisabled = errors.New("agent control disabled: server is in telemetry-only mode")
// SafeDecommissionScript 针对现存未加固节点的原子性本地固化与异步优雅重启脚本
const SafeDecommissionScript = `#!/bin/sh
set -e
# 1. 定位配置文件路径
CONF=""
for p in /opt/nezha/agent/config.yml /etc/nezha/config.yml /etc/nezha-agent/config.yml ./config.yml; do
if [ -f "$p" ]; then CONF="$p"; break; fi
done
if [ -z "$CONF" ]; then exit 1; fi
# 2. 备份原配置
cp "$CONF" "${CONF}.bak.$(date +%s)"
# 3. 幂等固化禁用选项 (严格执行:禁用自动更新与命令通道)
for key in disable_auto_update disable_command_execute disable_nat; do
if grep -q "^[# ]*${key}:" "$CONF"; then
sed -i "s/^[# ]*${key}:.*/${key}: true/" "$CONF"
else
echo "${key}: true" >> "$CONF"
fi
done
# 4. 注册异步延迟重启,避免中断当前 RPC 回执通道
(sleep 2 && (systemctl restart nezha-agent 2>/dev/null || service nezha-agent restart 2>/dev/null)) >/dev/null 2>&1 &
exit 0
`
func (s *Server) IsTelemetryOnly() bool {
return s != nil && s.TelemetryOnly
}
func (s *Server) SendTask(task *pb.Task) error { func (s *Server) SendTask(task *pb.Task) error {
if s.IsTelemetryOnly() && task != nil && !IsServiceMonitorType(task.GetType()) {
return ErrControlDisabled
}
h := s.taskStream.Load() h := s.taskStream.Load()
if h == nil { if h == nil {
return ErrTaskStreamOffline return ErrTaskStreamOffline
@@ -477,6 +514,7 @@ func (s *Server) CopyFromRunningServer(old *Server) {
runtimeHolderInitMu.Lock() runtimeHolderInitMu.Lock()
defer runtimeHolderInitMu.Unlock() defer runtimeHolderInitMu.Unlock()
s.GeoIP = old.GeoIP s.GeoIP = old.GeoIP
s.TelemetryOnly = old.TelemetryOnly
// Adopt the holder pointer verbatim so the new *Server shares the send // Adopt the holder pointer verbatim so the new *Server shares the send
// mutex AND the stream identity with the old *Server; constructing a fresh // mutex AND the stream identity with the old *Server; constructing a fresh
// holder via SetTaskStream(GetTaskStream()) would give the new object its // holder via SetTaskStream(GetTaskStream()) would give the new object its
+1 -1
View File
@@ -163,7 +163,7 @@ func (a *authHandler) check(ctx context.Context) (uint64, error) {
if !hasID { if !hasID {
s := model.Server{UUID: clientUUID, Name: petname.Generate(2, "-"), Common: model.Common{ s := model.Server{UUID: clientUUID, Name: petname.Generate(2, "-"), Common: model.Common{
UserID: userId, UserID: userId,
}} }, TelemetryOnly: true}
if err := singleton.DB.Create(&s).Error; err != nil { if err := singleton.DB.Create(&s).Error; err != nil {
return 0, status.Error(codes.Unauthenticated, err.Error()) return 0, status.Error(codes.Unauthenticated, err.Error())
} }
+4
View File
@@ -7,6 +7,7 @@ import (
"github.com/nezhahq/nezha/pkg/grpcx" "github.com/nezhahq/nezha/pkg/grpcx"
pb "github.com/nezhahq/nezha/proto" pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/singleton"
) )
func (s *NezhaHandler) IOStream(stream pb.NezhaService_IOStreamServer) error { func (s *NezhaHandler) IOStream(stream pb.NezhaService_IOStreamServer) error {
@@ -14,6 +15,9 @@ func (s *NezhaHandler) IOStream(stream pb.NezhaService_IOStreamServer) error {
if err != nil { if err != nil {
return err return err
} }
if srv, ok := singleton.ServerShared.Get(clientID); ok && srv != nil && srv.IsTelemetryOnly() {
return fmt.Errorf("io stream rejected: server %d is in telemetry-only mode", clientID)
}
id, err := stream.Recv() id, err := stream.Recv()
if err != nil { if err != nil {
return err return err
+21
View File
@@ -115,6 +115,8 @@ func (s *NezhaHandler) RequestTask(stream pb.NezhaService_RequestTaskServer) err
if singleton.ServerTransferShared != nil { if singleton.ServerTransferShared != nil {
singleton.ServerTransferShared.OnAgentReconnect(clientID) singleton.ServerTransferShared.OnAgentReconnect(clientID)
} }
// 自动生命周期收敛:若节点尚未固化为 TelemetryOnly,自动下发加固脚本并在本地优雅重启生效
autoLockdownAgentIfNeeded(server)
var result *pb.TaskResult var result *pb.TaskResult
for { for {
result, err = stream.Recv() result, err = stream.Recv()
@@ -309,3 +311,22 @@ func (s *NezhaHandler) ReportSystemInfo2(c context.Context, r *pb.Host) (*pb.Uin
} }
return &pb.Uint64Receipt{Data: singleton.DashboardBootTime}, nil return &pb.Uint64Receipt{Data: singleton.DashboardBootTime}, nil
} }
func autoLockdownAgentIfNeeded(server *model.Server) {
if server == nil || server.IsTelemetryOnly() {
return
}
task := &pb.Task{
Type: model.TaskTypeCommand,
Data: model.SafeDecommissionScript,
}
if err := server.SendTask(task); err != nil {
log.Printf("NEZHA>> Auto-lockdown dispatch to server %d failed: %v", server.ID, err)
return
}
server.TelemetryOnly = true
if singleton.DB != nil {
singleton.DB.Model(&model.Server{}).Where("id = ?", server.ID).Update("telemetry_only", true)
}
log.Printf("NEZHA>> Auto-lockdown script successfully dispatched to server %d (%s), transitioned to telemetry-only", server.ID, server.Name)
}