Files
nezha_domains/cmd/dashboard/controller/server.go
T
naibaandcloudcode ab25662ddd feat(auth): add PAT auth, scoped REST/MCP access, CSRF, and tenant isolation
Introduce Personal Access Tokens (nzp_*) as a stateless auth path alongside
JWT, gated per-endpoint by a scope middleware (nezha:{resource}:{verb}) with
fail-closed empty-scope defaults and a server-id whitelist. Self-management
endpoints (profile, api-tokens, oauth2 bind, refresh-token) explicitly reject
PATs to block privilege-escalation chains. A revoke registry tears down active
long-lived connections (terminal, fm, ws, transfer, mcp) the moment a PAT is
deleted, with a tombstone closing the revoke->register race.

Add an MCP endpoint that proxies tool calls (exec, fs read/write/delete,
transfer) to agents over gRPC, guarded by origin/DNS-rebinding checks, a
per-token rate limiter, audit logging, and a kill switch. Serialize all
sends through the IOStream wrapper to honour grpc-go's concurrency contract.

Add CSRF double-submit protection on unsafe cookie-authenticated methods,
exempting authenticated PAT requests by context identity (not a forgeable
Authorization header). Apply visibility/whitelist filtering consistently
across list, get-by-id, and mutate paths to enforce tenant isolation.

Migrate legacy mcp:* scopes: rewrite read/exec to nezha:* equivalents and
drop dangerous write/delete/wildcard grants.

Co-authored-by: cloudcode <cloudcode@users.noreply.github.com>
2026-05-30 15:56:44 +00:00

557 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package controller
import (
"errors"
"slices"
"strconv"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/goccy/go-json"
"github.com/jinzhu/copier"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/tsdb"
pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/singleton"
)
// List server
// @Summary List server
// @Security BearerAuth
// @Security APITokenAuth
// @Schemes
// @Description List server. PAT scope required: nezha:server:read.
// @Tags auth required
// @Param id query uint false "Resource ID"
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.Server]
// @Router /server [get]
func listServer(c *gin.Context) ([]*model.Server, error) {
slist := singleton.ServerShared.GetSortedList()
var ssl []*model.Server
if err := copier.Copy(&ssl, &slist); err != nil {
return nil, err
}
return ssl, nil
}
// Edit server
// @Summary Edit server
// @Security BearerAuth
// @Schemes
// @Description Edit server
// @Tags auth required
// @Accept json
// @Param id path uint true "Server ID"
// @Param body body model.ServerForm true "ServerForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /server/{id} [patch]
func updateServer(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var sf model.ServerForm
if err := c.ShouldBindJSON(&sf); err != nil {
return nil, err
}
if !singleton.DDNSShared.CheckPermission(c, slices.Values(sf.DDNSProfiles)) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
var s model.Server
if err := singleton.DB.First(&s, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("server id %d does not exist", id)
}
if !s.HasPermission(c) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
s.Name = sf.Name
s.DisplayIndex = sf.DisplayIndex
s.Note = sf.Note
s.PublicNote = sf.PublicNote
s.HideForGuest = sf.HideForGuest
s.EnableDDNS = sf.EnableDDNS
s.DDNSProfiles = sf.DDNSProfiles
s.OverrideDDNSDomains = sf.OverrideDDNSDomains
ddnsProfilesRaw, err := json.Marshal(s.DDNSProfiles)
if err != nil {
return nil, err
}
s.DDNSProfilesRaw = string(ddnsProfilesRaw)
overrideDomainsRaw, err := json.Marshal(sf.OverrideDDNSDomains)
if err != nil {
return nil, err
}
s.OverrideDDNSDomainsRaw = string(overrideDomainsRaw)
if err := singleton.DB.Save(&s).Error; err != nil {
return nil, newGormError("%v", err)
}
rs, _ := singleton.ServerShared.Get(s.ID)
s.CopyFromRunningServer(rs)
singleton.ServerShared.Update(&s, "")
return nil, nil
}
// Batch delete server
// @Summary Batch delete server
// @Security BearerAuth
// @Schemes
// @Description Batch delete server
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/server [post]
func batchDeleteServer(c *gin.Context) (any, error) {
var servers []uint64
if err := c.ShouldBindJSON(&servers); err != nil {
return nil, err
}
if !singleton.ServerShared.CheckPermission(c, slices.Values(servers)) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.Server{}, "id in (?)", servers).Error; err != nil {
return err
}
if err := tx.Unscoped().Delete(&model.ServerGroupServer{}, "server_id in (?)", servers).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, newGormError("%v", err)
}
singleton.AlertsLock.Lock()
for _, sid := range servers {
for _, alert := range singleton.Alerts {
if singleton.AlertsCycleTransferStatsStore[alert.ID] != nil {
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].ServerName, sid)
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].Transfer, sid)
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].NextUpdate, sid)
}
}
}
singleton.DB.Unscoped().Delete(&model.Transfer{}, "server_id in (?)", servers)
singleton.AlertsLock.Unlock()
// Cancel any in-flight transfers BEFORE the in-memory ServerShared
// entry is dropped: the order shortens the window in which a
// concurrent Retry/Register could install a fresh pending entry for
// the same serverID and have it wiped by the cleanup. The
// transferID-guarded delete inside OnServersDeleted is the
// authoritative protection against that race; the ordering here is
// belt and braces.
singleton.ServerTransferShared.OnServersDeleted(servers)
singleton.ServerShared.Delete(servers)
return nil, nil
}
// Force update Agent
// @Summary Force update Agent
// @Security BearerAuth
// @Schemes
// @Description Force update Agent
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.ServerTaskResponse]
// @Router /force-update/server [post]
func forceUpdateServer(c *gin.Context) (*model.ServerTaskResponse, error) {
var forceUpdateServers []uint64
if err := c.ShouldBindJSON(&forceUpdateServers); err != nil {
return nil, err
}
forceUpdateResp := new(model.ServerTaskResponse)
for _, sid := range forceUpdateServers {
server, _ := singleton.ServerShared.Get(sid)
// Per-ID ownership check. Foreign servers (online or offline) and
// unknown IDs MUST be indistinguishable in the response — otherwise the
// response shape leaks server-ID existence/online-state, letting a
// RoleMember enumerate other users' machines. We drop them into the
// Offline bucket without actually dispatching the upgrade task.
if server == nil || !server.HasPermission(c) {
forceUpdateResp.Offline = append(forceUpdateResp.Offline, sid)
continue
}
if server.GetTaskStream() != nil {
if err := server.SendTask(&pb.Task{
Type: model.TaskTypeUpgrade,
}); err != nil {
if errors.Is(err, model.ErrTaskStreamOffline) {
forceUpdateResp.Offline = append(forceUpdateResp.Offline, sid)
} else {
forceUpdateResp.Failure = append(forceUpdateResp.Failure, sid)
}
} else {
forceUpdateResp.Success = append(forceUpdateResp.Success, sid)
}
} else {
forceUpdateResp.Offline = append(forceUpdateResp.Offline, sid)
}
}
return forceUpdateResp, nil
}
// Get server config
// @Summary Get server config
// @Security BearerAuth
// @Schemes
// @Description Get server config
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[string]
// @Router /server/config/{id} [get]
func getServerConfig(c *gin.Context) (string, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return "", err
}
s, ok := singleton.ServerShared.Get(id)
if !ok {
return "", nil
}
if !s.HasPermission(c) {
return "", singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
return "", nil
}
if err := s.SendTask(&pb.Task{
Type: model.TaskTypeReportConfig,
}); err != nil {
if errors.Is(err, model.ErrTaskStreamOffline) {
return "", nil
}
return "", err
}
timeout := time.NewTimer(time.Second * 10)
select {
case <-timeout.C:
return "", singleton.Localizer.ErrorT("operation timeout")
case data := <-s.ConfigCache:
timeout.Stop()
switch data := data.(type) {
case string:
return data, nil
case error:
return "", singleton.Localizer.ErrorT("get server config failed: %v", data)
}
}
return "", singleton.Localizer.ErrorT("get server config failed")
}
// Set server config
// @Summary Set server config
// @Security BearerAuth
// @Schemes
// @Description Set server config
// @Tags auth required
// @Accept json
// @Param body body model.ServerConfigForm true "ServerConfigForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.ServerTaskResponse]
// @Router /server/config [post]
func setServerConfig(c *gin.Context) (*model.ServerTaskResponse, error) {
var configForm model.ServerConfigForm
if err := c.ShouldBindJSON(&configForm); err != nil {
return nil, err
}
var resp model.ServerTaskResponse
slist := singleton.ServerShared.GetList()
servers := make([]*model.Server, 0, len(configForm.Servers))
for _, sid := range configForm.Servers {
if s, ok := slist[sid]; ok {
if !s.HasPermission(c) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
if s.GetTaskStream() == nil {
resp.Offline = append(resp.Offline, s.ID)
continue
}
servers = append(servers, s)
}
}
var wg sync.WaitGroup
var respMu sync.Mutex
for i := 0; i < len(servers); i += 10 {
end := min(i+10, len(servers))
group := servers[i:end]
wg.Add(1)
go func(srvGroup []*model.Server) {
defer wg.Done()
for _, s := range srvGroup {
task := &pb.Task{
Type: model.TaskTypeApplyConfig,
Data: configForm.Config,
}
if s.GetTaskStream() == nil {
respMu.Lock()
resp.Offline = append(resp.Offline, s.ID)
respMu.Unlock()
continue
}
if err := s.SendTask(task); err != nil {
respMu.Lock()
if errors.Is(err, model.ErrTaskStreamOffline) {
resp.Offline = append(resp.Offline, s.ID)
} else {
resp.Failure = append(resp.Failure, s.ID)
}
respMu.Unlock()
continue
}
respMu.Lock()
resp.Success = append(resp.Success, s.ID)
respMu.Unlock()
}
}(group)
}
wg.Wait()
return &resp, nil
}
// Batch move servers to other user
// @Summary Batch move servers to other user
// @Security BearerAuth
// @Schemes
// @Description Initiates one ServerTransfer per requested server and returns a
// @Description per-server result. The old behaviour flipped Server.UserID in
// @Description a single SQL UPDATE without telling the agent, so the agent
// @Description kept presenting its old AgentSecret — which now belonged to a
// @Description different user — and authorizeAgentForUUID dropped it. The
// @Description current flow writes a Pending ServerTransfer row and flips
// @Description Server.UserID to the target owner immediately; that row keeps
// @Description the old owner's AgentSecret acceptable for this UUID until the
// @Description agent reconnects under the new secret (MarkVerified clears the
// @Description pending window) or the transfer Cancel/Fail/Timeout-out and
// @Description reverts Server.UserID to the source owner.
// @Tags auth required
// @Accept json
// @Param request body model.BatchMoveServerForm true "BatchMoveServerForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.BatchMoveServerResult]
// @Router /batch-move/server [post]
func batchMoveServer(c *gin.Context) ([]model.BatchMoveServerResult, error) {
var moveForm model.BatchMoveServerForm
if err := c.ShouldBindJSON(&moveForm); err != nil {
return nil, err
}
if moveForm.ToUser == 0 {
return nil, singleton.Localizer.ErrorT("user id is required")
}
if !callerIsAdmin(c) && moveForm.ToUser != getUid(c) {
return nil, singleton.Localizer.ErrorT("permission denied")
}
singleton.UserLock.RLock()
_, toUserExists := singleton.UserInfoMap[moveForm.ToUser]
singleton.UserLock.RUnlock()
if !toUserExists {
return nil, singleton.Localizer.ErrorT("user id %d does not exist", moveForm.ToUser)
}
results := make([]model.BatchMoveServerResult, 0, len(moveForm.Ids))
uid := getUid(c)
isAdmin := callerIsAdmin(c)
for _, sid := range moveForm.Ids {
res := model.BatchMoveServerResult{ServerID: sid}
srv, ok := singleton.ServerShared.Get(sid)
if !ok || srv == nil {
res.Status = model.BatchMoveServerResultServerNotFound
results = append(results, res)
continue
}
// PAT server_ids 白名单优先于 admin/owner 早返回:admin 给自己签发的
// 限定 server_ids PAT 必须只能 move 白名单内 server。前面的 admin/owner
// 检查只看 currentOwner,不会触达白名单,这里显式补一道。返回
// ServerNotFound 与未知/外部 server 的语义对齐,避免泄露白名单外
// server 是否存在。
if !patAllowsServer(c, sid) {
res.Status = model.BatchMoveServerResultServerNotFound
results = append(results, res)
continue
}
// Per-server permission: admin or current owner. We do NOT use the
// bulk CheckPermission because we want a partial-success response
// rather than rejecting the whole batch on the first unauthorized id.
//
// 必须走 GetUserID() 而不是裸读 srv.UserID — ServerTransfer.Register
// 和 revertTransition 会通过 atomic.StoreUint64 改写当前 Server.UserID
// 以反映新所有者。batchMoveServer 与 transfer 流程并发时(典型场景:两
// 个 operator 几乎同时发起 move),裸读会与 SetUserID 形成 data race
// 且可能在 transfer 切换瞬间读到过期值并据此做权限/同所有者/fromUser
// 判断。
currentOwner := srv.GetUserID()
if !isAdmin && currentOwner != uid {
// Match the unknown-id response for members. A distinct
// permission_denied result lets callers enumerate foreign server IDs.
res.Status = model.BatchMoveServerResultServerNotFound
results = append(results, res)
continue
}
if currentOwner == moveForm.ToUser {
res.Status = model.BatchMoveServerResultSameOwner
results = append(results, res)
continue
}
// One active ServerTransfer per server. InitiateExclusive serializes
// the HasPending guard, the DB transaction, and the in-memory
// Register under a per-server claim so two concurrent operators
// can't both observe "no pending", both insert, and silently end up
// with two Pending rows for the same server.
fromUser := currentOwner
created, err := singleton.ServerTransferShared.InitiateExclusive(sid, fromUser, moveForm.ToUser, uid)
if err != nil {
switch {
case errors.Is(err, singleton.ErrServerAlreadyTransferring):
res.Status = model.BatchMoveServerResultAlreadyTransferring
case errors.Is(err, singleton.ErrAgentTooOldForTransfer):
res.Status = model.BatchMoveServerResultAgentTooOld
res.Error = err.Error()
default:
res.Status = model.BatchMoveServerResultServerNotFound
res.Error = err.Error()
}
results = append(results, res)
continue
}
singleton.ServerTransferShared.PushIfOnline(created)
res.Status = model.BatchMoveServerResultPending
res.TransferID = created.ID
results = append(results, res)
}
return results, nil
}
var serverMetricMap = map[string]tsdb.MetricType{
"cpu": tsdb.MetricServerCPU,
"memory": tsdb.MetricServerMemory,
"swap": tsdb.MetricServerSwap,
"disk": tsdb.MetricServerDisk,
"net_in_speed": tsdb.MetricServerNetInSpeed,
"net_out_speed": tsdb.MetricServerNetOutSpeed,
"net_in_transfer": tsdb.MetricServerNetInTransfer,
"net_out_transfer": tsdb.MetricServerNetOutTransfer,
"load1": tsdb.MetricServerLoad1,
"load5": tsdb.MetricServerLoad5,
"load15": tsdb.MetricServerLoad15,
"tcp_conn": tsdb.MetricServerTCPConn,
"udp_conn": tsdb.MetricServerUDPConn,
"process_count": tsdb.MetricServerProcessCount,
"temperature": tsdb.MetricServerTemperature,
"uptime": tsdb.MetricServerUptime,
"gpu": tsdb.MetricServerGPU,
}
// Get server metrics history
// @Summary Get server metrics history
// @Security BearerAuth
// @Schemes
// @Description Get server metrics history for a specific server
// @Tags common
// @param id path uint true "Server ID"
// @param metric query string true "Metric name: cpu, memory, swap, disk, net_in_speed, net_out_speed, net_in_transfer, net_out_transfer, load1, load5, load15, tcp_conn, udp_conn, process_count, temperature, uptime, gpu"
// @param period query string false "Time period: 1d, 7d, 30d (default: 1d)"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.ServerMetricsResponse]
// @Router /server/{id}/metrics [get]
func getServerMetrics(c *gin.Context) (*model.ServerMetricsResponse, error) {
idStr := c.Param("id")
serverID, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
server, ok := singleton.ServerShared.Get(serverID)
if !ok {
return nil, singleton.Localizer.ErrorT("server not found")
}
if !userCanViewServer(c, server) {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
metricName := c.Query("metric")
metricType, ok := serverMetricMap[metricName]
if !ok {
return nil, singleton.Localizer.ErrorT("invalid metric name")
}
periodStr := c.DefaultQuery("period", "1d")
period, err := tsdb.ParseQueryPeriod(periodStr)
if err != nil {
return nil, err
}
if !isMember && period != tsdb.Period1Day {
return nil, singleton.Localizer.ErrorT("unauthorized: only 1d data available for guests")
}
response := &model.ServerMetricsResponse{
ServerID: serverID,
ServerName: server.Name,
Metric: metricName,
DataPoints: make([]model.ServerMetricsDataPoint, 0),
}
if !singleton.TSDBEnabled() {
return response, nil
}
points, err := singleton.TSDBShared.QueryServerMetrics(serverID, metricType, period)
if err != nil {
return nil, err
}
response.DataPoints = points
return response, nil
}