fix(mcp): harden dashboard dispatch lifecycle

Co-authored-by: naiba/CloudCode <hi+cloudcode@nai.ba>
This commit is contained in:
naiba
2026-07-20 04:32:16 +00:00
co-authored by naiba/CloudCode
parent 2640b86d3a
commit 3f6de265a0
48 changed files with 1332 additions and 265 deletions
-94
View File
@@ -19,12 +19,8 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/hashicorp/go-uuid"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
@@ -931,96 +927,6 @@ func xferSizeFromU64(raw uint64) (int64, error) {
return int64(raw), nil
}
// openFsTransferStream 走 IOStream 通道与目标 agent 建立一条专用大文件流。
// 返回的 io.ReadWriteCloser 既能 Read(接收 agent→dashboard 字节)又能 Write
// (发送 dashboard→agent 字节);调用方通过 readXferFixedHeader 解析控制帧。
//
// 内部步骤:
// 1. 分配 streamIdCreateStream(streamId, 0, serverID) 在 NezhaHandler 注册
// 一个 ioStreamContexttargetServerID 用于 agent 侧 stream 归属校验。
// 2. 通过 server 当前的 RequestTask 流发 TaskTypeFsTransfer,把 streamId +
// req JSON 下发给 agent。
// 3. 等 agent 通过 IOStream() RPC 完成 magic 引导帧并 AgentConnected。
// 4. 返回 agent 端流和 cleanupCloseStream)。
//
// 任何步骤失败都会 CloseStream 释放资源;调用方只需在 defer cleanup() 即可。
func openFsTransferStream(ctx context.Context, serverID uint64, req *model.FsTransferRequest) (io.ReadWriteCloser, func(), error) {
if singleton.Conf == nil || !singleton.Conf.MCPEnabled() {
return nil, func() {}, errors.New("MCP is disabled by the dashboard administrator")
}
server, _ := singleton.ServerShared.Get(serverID)
if server == nil {
return nil, func() {}, errors.New("server offline")
}
if server.GetTaskStream() == nil {
return nil, func() {}, errors.New("server offline")
}
streamId, err := uuid.GenerateUUID()
if err != nil {
return nil, func() {}, err
}
req.StreamID = streamId
if err := rpc.NezhaHandlerSingleton.CreateStreamWithPurpose(streamId, 0, serverID, rpc.PurposeMCPTransfer); err != nil {
return nil, func() {}, err
}
cleanup := func() { _ = rpc.NezhaHandlerSingleton.CloseStream(streamId) }
body, err := json.Marshal(req)
if err != nil {
cleanup()
return nil, func() {}, err
}
// 关闭 entry-check 与 SendTask 之间的 TOCTOUstream 已注册后再复查一次
// kill switch / ctx 取消,确保 disable sweep 要么扫到这条已注册 stream、
// 要么这里读到 disabled,绝不会在禁用/吊销后仍把 transfer 任务发给 agent。
if singleton.Conf == nil || !singleton.Conf.MCPEnabled() {
cleanup()
return nil, func() {}, errors.New("MCP is disabled by the dashboard administrator")
}
if err := ctx.Err(); err != nil {
cleanup()
return nil, func() {}, err
}
if err := server.SendTask(&pb.Task{
Type: model.TaskTypeFsTransfer,
Data: string(body),
}); err != nil {
cleanup()
if errors.Is(err, model.ErrTaskStreamOffline) {
return nil, func() {}, errors.New("server offline")
}
return nil, func() {}, err
}
agentStream, ok := rpc.NezhaHandlerSingleton.WaitForAgent(ctx, streamId, 30*time.Second)
if !ok {
cleanup()
return nil, func() {}, errors.New("agent did not attach within 30s")
}
// After attach, the relay blocks in IOStreamWrapper.Read, which only
// honours the gRPC stream context — not this per-transfer ctx. Without
// the watcher below, a PAT revocation (deleteAPIToken cancels ctx) or a
// client disconnect would leave a stalled/compromised agent pinning this
// goroutine + IOStream until process restart. Closing the stream on
// ctx.Done() unblocks the agent-side handler (iw.Wait) so Read returns.
watcherDone := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = rpc.NezhaHandlerSingleton.CloseStream(streamId)
case <-watcherDone:
}
}()
wrappedCleanup := func() {
close(watcherDone)
cleanup()
}
return agentStream, wrappedCleanup, nil
}
// revalidateTransferEntry 在消费一次性 URL 时重新检查 mint 阶段的全部前置。
// 这是 mint→consume 之间发生权限变化(PAT 吊销、scope/whitelist 收紧、
// server 转手)时的兜底闸门:HMAC 签发与 sync.Map 一次性消费机制本身只能