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读会一直挂起。
修复方案
服务端侧修改(核心)
- 接受session类型channel后,不能直接丢弃所有incoming request,必须单独处理客户端发起的
exec/shell类会话请求,第一时间回送成功响应,之后再写入stdout数据。 - 必须单独起goroutine消费channel的读端(也就是客户端发来的stdin数据),否则缓冲区满后会导致通道写入阻塞。
- 数据写完后要发送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) }
客户端侧优化
你当前的客户端代码存在串行读的死锁风险,需要调整:
- 拿到pipe后必须调用
session.Run()/session.Shell()/session.Start()发起会话请求,不能只创建session就直接读。 - 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
相关产品推荐
相关产品推荐

