You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Golang SSH包实现服务端时channel.Write的stdout数据去向问题

问题原因

你遇到的stdout读取阻塞问题,核心是未遵守SSH会话通道的协议流程,对数据流触发条件理解有误:

  • SSH协议中,session类型通道的扩展数据(也就是你调用channel.Stderr().Write()发送的、类型码为1的扩展数据),客户端不依赖会话请求状态就会直接路由到StderrPipe,所以你读stderr始终正常。
  • 你直接调用channel.Write()发送的普通非扩展数据,确实是stdout对应的数据流,但客户端的StdoutPipe只有在收到服务端对session子请求(exec/shell/pty等)的成功响应之后,才会把普通通道的数据路由到stdout的读取器。如果你服务端没有正确回应该请求,或者在回应该请求之前就写入stdout数据,这些数据只会缓存在客户端channel的内部缓冲区,不会送到StdoutPipe,自然会永久阻塞。
  • 如果你服务端直接丢弃了客户端发来的session子请求没有响应,客户端的session始终处于未启动状态,stdout读会一直挂起。
修复方案

服务端侧修改(核心)

  1. 接受session类型channel后,不能直接丢弃所有incoming request,必须单独处理客户端发起的exec/shell类会话请求,第一时间回送成功响应,之后再写入stdout数据。
  2. 必须单独起goroutine消费channel的读端(也就是客户端发来的stdin数据),否则缓冲区满后会导致通道写入阻塞。
  3. 数据写完后要发送exit-status请求通知客户端命令执行完成,再关闭channel。

服务端正确代码示例:

// 处理新到的channel
for newCh := range sshServerConn.NewChans() {
    if newCh.ChannelType() != "session" {
        newCh.Reject(ssh.UnknownChannelType, "only support session channel")
        continue
    }
    channel, reqs, err := newCh.Accept()
    if err != nil {
        continue
    }

    // 处理channel上的请求,不能直接全量Discard
    go func(ch ssh.Channel, inReqs <-chan *ssh.Request) {
        defer ch.Close()
        // 消费stdin,避免阻塞
        go io.Copy(io.Discard, ch)

        for req := range inReqs {
            switch req.Type {
            case "exec", "shell":
                // 关键步骤:先回送请求成功响应,客户端收到后才会开始路由stdout数据
                req.Reply(true, nil)
                
                // 回完响应再写业务数据
                switch data.Type {
                case "stdout":
                    ch.Write([]byte("stdout message!"))
                case "stderr":
                    ch.Stderr().Write([]byte("stderr message!"))
                }

                // 发送退出状态码
                ch.SendRequest("exit-status", false, ssh.Marshal(struct{ ExitStatus uint32 }{0}))
                return
            default:
                // 其他不支持的请求直接回失败
                req.Reply(false, nil)
            }
        }
    }(channel, reqs)
}

客户端侧优化

你当前的客户端代码存在串行读的死锁风险,需要调整:

  1. 拿到pipe后必须调用session.Run()/session.Shell()/session.Start()发起会话请求,不能只创建session就直接读。
  2. stdout和stderr的读取必须放在两个独立goroutine中并发执行,避免因为通道缓冲区满导致双向阻塞。

客户端修正示例:

conn, err := gossh.Dial("tcp", "localhost:2222", config)
if err != nil {
    // 处理连接错误
}
defer conn.Close()

session, err := conn.NewSession()
if err != nil {
    // 处理会话创建错误
}
defer session.Close()

stderr, err := session.StderrPipe()
if err != nil {
    // 处理stderr pipe获取错误
}
stdout, err := session.StdoutPipe()
if err != nil {
    // 处理stdout pipe获取错误
}

// 并发读两个流,避免串行阻塞
var stdoutBuf, stderrBuf bytes.Buffer
go io.Copy(&stdoutBuf, stdout)
go io.Copy(&stderrBuf, stderr)

// 发起shell请求,触发会话启动
err = session.Shell()
if err != nil {
    // 处理请求发起错误
}
session.Wait()
// 此时stdoutBuf、stderrBuf中就是读取到的完整输出
补充说明

你和原生SSH服务端通信时正常,是因为原生sshd严格遵守了SSH协议流程,收到会话请求后会先回成功响应再输出数据,不会出现响应前写stdout的情况。

内容的提问来源于stack exchange,提问作者data princess

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.26 20:39:19