Files
nezha_domains/service/rpc/nezha.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

416 lines
14 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 rpc
import (
"context"
"errors"
"fmt"
"log"
"net"
"sync"
"time"
"github.com/jinzhu/copier"
geoipx "github.com/nezhahq/nezha/pkg/geoip"
"github.com/nezhahq/nezha/pkg/grpcx"
"github.com/nezhahq/nezha/pkg/tsdb"
"github.com/nezhahq/nezha/model"
pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/singleton"
)
var _ pb.NezhaServiceServer = (*NezhaHandler)(nil)
var NezhaHandlerSingleton *NezhaHandler
type NezhaHandler struct {
Auth *authHandler
ioStreams map[string]*ioStreamContext
ioStreamMutex *sync.RWMutex
}
func NewNezhaHandler() *NezhaHandler {
return &NezhaHandler{
Auth: &authHandler{},
ioStreamMutex: new(sync.RWMutex),
ioStreams: make(map[string]*ioStreamContext),
}
}
// attachRequestTaskStream resolves the server for clientID and publishes the
// task stream. It mirrors the !ok || server == nil guard the other RPC entry
// points use: the server can be deleted between CheckRequestTask and this
// lookup, in which case Get returns a nil *Server and SetTaskStream would
// panic.
func attachRequestTaskStream(clientID uint64, stream pb.NezhaService_RequestTaskServer) (*model.Server, bool) {
server, ok := singleton.ServerShared.Get(clientID)
if !ok || server == nil {
return nil, false
}
server.SetTaskStream(stream)
return server, true
}
// clearRequestTaskStream detaches the dropped stream from whichever *Server is
// currently published for clientID. Edit and transfer rotation publish a new
// *Server that adopts the same stream holder, so cleanup must target the live
// map entry; the captured server is only the fallback for a removed entry.
func clearRequestTaskStream(clientID uint64, captured *model.Server, stream pb.NezhaService_RequestTaskServer) {
if current, ok := singleton.ServerShared.Get(clientID); ok && current != nil {
current.ClearTaskStreamIfCurrent(stream)
return
}
captured.ClearTaskStreamIfCurrent(stream)
}
func (s *NezhaHandler) RequestTask(stream pb.NezhaService_RequestTaskServer) error {
var clientID uint64
var err error
if clientID, err = s.Auth.CheckRequestTask(stream.Context()); err != nil {
return err
}
server, ok := attachRequestTaskStream(clientID, stream)
if !ok {
return nil
}
defer clearRequestTaskStream(clientID, server, stream)
// If a transfer is mid-flight for this server, the agent has just brought
// up a fresh bidi stream — this is the moment to (re)deliver the
// ApplyConfig task carrying the new owner's AgentSecret. Pushes from
// dashboard mutation time are best-effort; this hook is the reliable
// re-delivery point that closes the offline-during-transfer gap.
if singleton.ServerTransferShared != nil {
singleton.ServerTransferShared.OnAgentReconnect(clientID)
}
var result *pb.TaskResult
for {
result, err = stream.Recv()
if err != nil {
log.Printf("NEZHA>> RequestTask error: %v, clientID: %d\n", err, clientID)
return err
}
switch result.GetType() {
case model.TaskTypeCommand:
// 处理上报的计划任务
cr, _ := singleton.CronShared.Get(result.GetId())
// 任务结果 ID 来自 agent,必须确认该 cron 本应派发给当前 reporter。
if singleton.CanReportCronResult(cr, server) {
// 保存当前服务器状态信息
var curServer model.Server
copier.Copy(&curServer, server)
if cr.PushSuccessful && result.GetSuccessful() {
singleton.NotificationShared.SendNotification(cr.NotificationGroupID, fmt.Sprintf("[%s] %s, %s\n%s", singleton.Localizer.T("Scheduled Task Executed Successfully"),
cr.Name, server.Name, result.GetData()), "", &curServer)
}
if !result.GetSuccessful() {
singleton.NotificationShared.SendNotification(cr.NotificationGroupID, fmt.Sprintf("[%s] %s, %s\n%s", singleton.Localizer.T("Scheduled Task Executed Failed"),
cr.Name, server.Name, result.GetData()), "", &curServer)
}
singleton.DB.Model(cr).Updates(model.Cron{
LastExecutedAt: time.Now().Add(time.Second * -1 * time.Duration(result.GetDelay())),
LastResult: result.GetSuccessful(),
})
}
case model.TaskTypeReportConfig:
if len(server.ConfigCache) < 1 {
if !result.GetSuccessful() {
server.ConfigCache <- errors.New(result.Data)
continue
}
server.ConfigCache <- result.Data
}
case model.TaskTypeServerTransferApply:
// Authorization: TaskResult.Id is attacker-controlled. Without
// the pending.ID == result.Id check below, agent A could cancel
// server B's in-flight transfer by spoofing B's transfer ID —
// same class of bug as commit 02129f1 in the cron path.
// Successful=true here is best-effort only; the authoritative
// verification is the agent's reconnect under the new secret.
if singleton.ServerTransferShared == nil {
continue
}
pending, ok := singleton.ServerTransferShared.LookupPending(clientID)
if !ok || pending.ID != result.GetId() {
log.Printf("NEZHA>> ServerTransferApply result ignored: clientID=%d reported transferID=%d but no matching pending transfer", clientID, result.GetId())
continue
}
if result.GetSuccessful() {
continue
}
if _, err := singleton.ServerTransferShared.MarkFailed(result.GetId(), result.GetData()); err != nil {
log.Printf("NEZHA>> ServerTransfer MarkFailed(%d) failed: %v", result.GetId(), err)
}
default:
if model.IsMCPRPCResult(result.GetType()) {
deliverMCPResultFromReporter(result, clientID)
continue
}
if model.IsServiceSentinelNeeded(result.GetType()) {
singleton.ServiceSentinelShared.Dispatch(singleton.ReportData{
Data: result,
Reporter: clientID,
})
}
}
}
}
func (s *NezhaHandler) ReportSystemState(stream pb.NezhaService_ReportSystemStateServer) error {
clientID, err := s.Auth.Check(stream.Context())
if err != nil {
return err
}
var state *pb.State
for {
state, err = stream.Recv()
if err != nil {
log.Printf("NEZHA>> ReportSystemState error: %v, clientID: %d\n", err, clientID)
return err
}
innerState := model.PB2State(state)
server, ok := singleton.ServerShared.Get(clientID)
if !ok || server == nil {
return errors.New("server not found")
}
server.LastActive = time.Now()
server.State = &innerState
if singleton.TSDBEnabled() {
maxTemp := 0.0
for _, t := range innerState.Temperatures {
if t.Temperature > maxTemp {
maxTemp = t.Temperature
}
}
maxGPU := 0.0
for _, g := range innerState.GPU {
if g > maxGPU {
maxGPU = g
}
}
if err := singleton.TSDBShared.WriteServerMetrics(&tsdb.ServerMetrics{
ServerID: clientID,
Timestamp: time.Now(),
CPU: innerState.CPU,
MemUsed: innerState.MemUsed,
SwapUsed: innerState.SwapUsed,
DiskUsed: innerState.DiskUsed,
NetInSpeed: innerState.NetInSpeed,
NetOutSpeed: innerState.NetOutSpeed,
NetInTransfer: innerState.NetInTransfer,
NetOutTransfer: innerState.NetOutTransfer,
Load1: innerState.Load1,
Load5: innerState.Load5,
Load15: innerState.Load15,
TCPConnCount: innerState.TcpConnCount,
UDPConnCount: innerState.UdpConnCount,
ProcessCount: innerState.ProcessCount,
Temperature: maxTemp,
Uptime: innerState.Uptime,
GPU: maxGPU,
}); err != nil {
log.Printf("NEZHA>> Failed to write server metrics to TSDB: %v", err)
}
}
// 应对 dashboard / agent 重启的情况,如果从未记录过,先打点,等到小时时间点时入库
if server.PrevTransferInSnapshot == 0 || server.PrevTransferOutSnapshot == 0 {
server.PrevTransferInSnapshot = state.NetInTransfer
server.PrevTransferOutSnapshot = state.NetOutTransfer
}
if err = stream.Send(&pb.Receipt{Proced: true}); err != nil {
return err
}
}
}
func (s *NezhaHandler) onReportSystemInfo(c context.Context, r *pb.Host) error {
var clientID uint64
var err error
if clientID, err = s.Auth.Check(c); err != nil {
return err
}
host := model.PB2Host(r)
server, ok := singleton.ServerShared.Get(clientID)
if !ok || server == nil {
return errors.New("server not found")
}
/**
* 这里的 singleton 中的数据都是关机前的旧数据
* 当 agent 重启时,bootTime 变大,agent 端会先上报 host 信息,然后上报 state 信息
* 这时可以借助上报顺序的空档,立即记录停机前的数据并重置 Prev* 数据,并由接下来的 state 方法重新赋值
*/
if !server.LastActive.IsZero() && host.BootTime > server.Host.BootTime {
singleton.RecordTransferHourlyUsage(server)
server.PrevTransferInSnapshot = 0
server.PrevTransferOutSnapshot = 0
}
server.Host = &host
return nil
}
func (s *NezhaHandler) ReportSystemInfo(c context.Context, r *pb.Host) (*pb.Receipt, error) {
if err := s.onReportSystemInfo(c, r); err != nil {
return nil, err
}
return &pb.Receipt{Proced: true}, nil
}
func (s *NezhaHandler) ReportSystemInfo2(c context.Context, r *pb.Host) (*pb.Uint64Receipt, error) {
if err := s.onReportSystemInfo(c, r); err != nil {
return nil, err
}
return &pb.Uint64Receipt{Data: singleton.DashboardBootTime}, nil
}
func (s *NezhaHandler) IOStream(stream pb.NezhaService_IOStreamServer) error {
clientID, err := s.Auth.Check(stream.Context())
if err != nil {
return err
}
id, err := stream.Recv()
if err != nil {
return err
}
// ff05ff05 是 Nezha 的魔数,用于标识流 ID。校验由 isValidIOStreamMagic 完成,
// 历史 inline 检查曾因 && 短路放过几乎全部非魔数 payload (byte0==0xff 即通过)。
if id == nil || !isValidIOStreamMagic(id.Data) {
return fmt.Errorf("invalid stream id")
}
streamId := string(id.Data[4:])
// agent 侧归属校验:只有 createTerminal / createFM / ServeNAT 选定的目标 server
// 才能接管该 stream。漏掉这一步等同于把 terminal / fm / NAT 会话向所有合法 agent
// 开放(任何获得 streamId 的 agent 都能抢答),构成 session-hijack RCE 中介。
// 这是 commit 6661d6auser 侧归属校验)的对偶补丁。先校验后启 keepalive,
// 避免未授权 agent 触发悬空 goroutine 持续向其发心跳。
if !s.IsStreamAuthorizedForAgent(streamId, clientID) {
return fmt.Errorf("stream not authorized for agent")
}
if _, err := s.GetStream(streamId); err != nil {
return err
}
iw := grpcx.NewIOStreamWrapper(stream)
// Keepalive MUST go through the wrapper so it shares the same sendMu as
// MCP fs.transfer / terminal / fm Writers. Calling stream.Send directly
// here used to race those Writers — grpc-go forbids concurrent SendMsg
// on the same stream. The wrapper's sendMu is the dashboard-side dual
// of agent/cmd/agent/mcp_fs_transfer.go's serialIOStreamSender.
keepaliveDone := make(chan struct{})
go func() {
defer close(keepaliveDone)
ticker := time.NewTicker(time.Second * 30)
defer ticker.Stop()
for {
select {
case <-iw.Context().Done():
return
case <-iw.Done():
// 业务侧(CloseStream / RevokeStreamsForPurpose)调过
// iw.Close()。即便底层 gRPC stream context 尚未取消,也
// 必须立刻收手——否则要再等一整个 30s tickhandler 在
// iw.Wait() 之后又得多等一拍 keepaliveDone 才能返回。
return
case <-ticker.C:
if err := iw.SendKeepalive(); err != nil {
log.Printf("NEZHA>> IOStream keepAlive error: %v\n", err)
return
}
}
}
}()
if err := s.AgentConnected(streamId, iw); err != nil {
return err
}
iw.Wait()
<-keepaliveDone
return nil
}
func (s *NezhaHandler) ReportGeoIP(c context.Context, r *pb.GeoIP) (*pb.GeoIP, error) {
var clientID uint64
var err error
if clientID, err = s.Auth.Check(c); err != nil {
return nil, err
}
geoip := model.PB2GeoIP(r)
use6 := r.GetUse6()
if geoip.IP.IPv4Addr == "" && geoip.IP.IPv6Addr == "" {
ip, _ := c.Value(model.CtxKeyRealIP{}).(string)
if ip == "" {
ip, _ = c.Value(model.CtxKeyConnectingIP{}).(string)
}
geoip.IP.IPv4Addr = ip
}
joinedIP := geoip.IP.Join()
server, ok := singleton.ServerShared.Get(clientID)
if !ok || server == nil {
return nil, fmt.Errorf("server not found")
}
// 检查并更新DDNS
if server.EnableDDNS && joinedIP != "" &&
(server.GeoIP == nil || server.GeoIP.IP != geoip.IP) {
ipv4 := geoip.IP.IPv4Addr
ipv6 := geoip.IP.IPv6Addr
if err := singleton.ServerShared.UpdateDDNS(server, &model.IP{IPv4Addr: ipv4, IPv6Addr: ipv6}); err != nil {
log.Printf("NEZHA>> Failed to update DDNS for server %d: %v", err, server.ID)
}
}
// 发送IP变动通知
if server.GeoIP != nil && singleton.Conf.EnableIPChangeNotification &&
((singleton.Conf.Cover == model.ConfigCoverAll && !singleton.Conf.IgnoredIPNotificationServerIDs[clientID]) ||
(singleton.Conf.Cover == model.ConfigCoverIgnoreAll && singleton.Conf.IgnoredIPNotificationServerIDs[clientID])) &&
server.GeoIP.IP.Join() != "" &&
joinedIP != "" &&
server.GeoIP.IP != geoip.IP {
singleton.NotificationShared.SendNotification(singleton.Conf.IPChangeNotificationGroupID,
fmt.Sprintf(
"[%s] %s, %s => %s",
singleton.Localizer.T("IP Changed"),
server.Name, singleton.IPDesensitize(server.GeoIP.IP.Join()),
singleton.IPDesensitize(joinedIP),
),
"")
}
// 根据内置数据库查询 IP 地理位置
var ip string
if geoip.IP.IPv6Addr != "" && (use6 || geoip.IP.IPv4Addr == "") {
ip = geoip.IP.IPv6Addr
} else {
ip = geoip.IP.IPv4Addr
}
netIP := net.ParseIP(ip)
location, err := geoipx.Lookup(netIP)
if err != nil {
log.Printf("NEZHA>> geoip.Lookup: %v", err)
}
geoip.CountryCode = location
// 将地区码写入到 Host
server.GeoIP = &geoip
return &pb.GeoIP{Ip: nil, CountryCode: location, DashboardBootTime: singleton.DashboardBootTime}, nil
}