Files
nezha_domains/model/server_transfer.go
naibaandcloudcode e8dabf5bc6 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

139 lines
6.7 KiB
Go

package model
import (
"time"
"github.com/gin-gonic/gin"
)
// ServerTransferStatus represents the lifecycle state of a server ownership
// transfer. A transfer's life starts at Pending (server.user_id has been
// flipped to the new owner; agent still authenticates with the old owner's
// AgentSecret) and ends in exactly one of the terminal states.
type ServerTransferStatus uint8
const (
// ServerTransferStatusPending means the dashboard has flipped Server.UserID
// to the new owner and queued an ApplyConfig task to swap the agent's
// client_secret. Auth still accepts the old owner's AgentSecret for this
// UUID until verification arrives or the transfer times out.
ServerTransferStatusPending ServerTransferStatus = iota
// ServerTransferStatusVerified means the agent successfully reconnected
// using the new owner's AgentSecret. Auth no longer tolerates the old
// owner's secret on this UUID.
ServerTransferStatusVerified
// ServerTransferStatusFailed means the agent explicitly reported the
// ApplyConfig task as unsuccessful (e.g. DisableCommandExecute). The
// dashboard has rolled Server.UserID back to FromUserID.
ServerTransferStatusFailed
// ServerTransferStatusTimeout means the verification window expired
// without the agent reconnecting under the new secret. The dashboard has
// rolled Server.UserID back to FromUserID.
ServerTransferStatusTimeout
// ServerTransferStatusCancelled means an administrator cancelled the
// transfer before any verification event was observed. The dashboard has
// rolled Server.UserID back to FromUserID.
ServerTransferStatusCancelled
)
// IsTerminal reports whether the status represents a settled transfer. Only
// terminal transfers are eligible for retry and they will never be in the
// pending index.
func (s ServerTransferStatus) IsTerminal() bool {
return s != ServerTransferStatusPending
}
// ServerTransfer records a single attempt to transfer ownership of one server
// to another user. It is the source of truth for the auth-tolerance window
// during a transfer — service/rpc.authorizeAgentForUUID consults the pending
// index built from this table to decide whether to accept the old owner's
// AgentSecret on the affected UUID.
//
// Naming note: the existing model.Transfer records hourly traffic snapshots
// and is unrelated. This entity is named ServerTransfer to disambiguate.
type ServerTransfer struct {
Common
ServerID uint64 `json:"server_id" gorm:"index"`
FromUserID uint64 `json:"from_user_id"`
ToUserID uint64 `json:"to_user_id"`
InitiatorID uint64 `json:"initiator_id"`
Status ServerTransferStatus `json:"status" gorm:"index"`
LastError string `json:"last_error,omitempty"`
AckedAt *time.Time `json:"acked_at,omitempty"`
// HandshakeSecret is a per-transfer random credential that PushIfOnline
// delivers in place of the destination user's global AgentSecret. The
// agent treats it as a temporary handshake token: it rotates to this
// secret on the 10s reload, reconnects, and the dashboard's auth path
// recognises it as proof of transfer delivery (MarkVerified). It is
// scoped to this single transfer and to this single UUID — leaking it
// to the previous owner who hijacks the stream still does NOT expose
// the destination user's other agents. Never returned to API clients.
HandshakeSecret string `json:"-" gorm:"type:char(32)"`
// RevertHandshakeSecret is the same idea for the rollback path: when
// the dashboard pushes a revert ApplyConfig over a stream now held by
// the destination user, we must not embed the source user's global
// AgentSecret. Instead the agent rotates back through this token, which
// is recognised by the auth path during the revert window only.
RevertHandshakeSecret string `json:"-" gorm:"type:char(32)"`
}
// HasPermission overrides Common.HasPermission so a transfer is visible to
// admins, the source user, the destination user, and the initiator. Listing
// uses this to filter what the caller can see; mutating endpoints (cancel,
// retry) layer additional checks on top.
//
// PAT server_ids whitelist is evaluated FIRST, before the admin short-
// circuit, so an admin-issued PAT scoped to a subset of servers cannot
// widen reach by virtue of the caller being an admin. JWT callers (no PAT
// in context) skip the whitelist check.
func (t *ServerTransfer) HasPermission(ctx *gin.Context) bool {
auth, ok := ctx.Get(CtxKeyAuthorizedUser)
if !ok {
return false
}
if v, ok := ctx.Get(CtxKeyAPIToken); ok {
if tok, _ := v.(APITokenAccessor); tok != nil && !tok.CanAccessServer(t.ServerID) {
return false
}
}
user := *auth.(*User)
if user.Role == RoleAdmin {
return true
}
return user.ID == t.FromUserID || user.ID == t.ToUserID || user.ID == t.InitiatorID
}
// BatchMoveServerResultStatus is the per-server outcome returned by the
// batch-move endpoint. It maps to TransferStatus for transfers that were
// successfully created, plus extra synchronous-failure modes (permission,
// duplicate active transfer, missing server) that never produce a row.
type BatchMoveServerResultStatus string
const (
// BatchMoveServerResultPending: ServerTransfer row created, agent push
// in progress. Callers should watch the WS for terminal status.
BatchMoveServerResultPending BatchMoveServerResultStatus = "pending"
// BatchMoveServerResultPermissionDenied: caller cannot move this server.
BatchMoveServerResultPermissionDenied BatchMoveServerResultStatus = "permission_denied"
// BatchMoveServerResultAlreadyTransferring: server already has an in-flight
// ServerTransfer row, cancel or wait first.
BatchMoveServerResultAlreadyTransferring BatchMoveServerResultStatus = "already_transferring"
// BatchMoveServerResultServerNotFound: server id does not exist.
BatchMoveServerResultServerNotFound BatchMoveServerResultStatus = "server_not_found"
// BatchMoveServerResultSameOwner: target user already owns this server.
BatchMoveServerResultSameOwner BatchMoveServerResultStatus = "same_owner"
// BatchMoveServerResultAgentTooOld: agent build does not understand
// TaskTypeServerTransferApply, so the rotation would never complete and
// dashboard refuses to start it. Operator must upgrade the agent.
BatchMoveServerResultAgentTooOld BatchMoveServerResultStatus = "agent_too_old"
)
// BatchMoveServerResult is one entry in the batchMoveServer response, one
// per requested server id, in the same order.
type BatchMoveServerResult struct {
ServerID uint64 `json:"server_id"`
Status BatchMoveServerResultStatus `json:"status"`
TransferID uint64 `json:"transfer_id,omitempty"`
Error string `json:"error,omitempty"`
}