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

Go SSH Server:如何将stdout/stderr返回给SSH客户端?

问题:自研Go SSH服务器命令执行后客户端挂起

我正在开发一个配合自研Go SSH客户端的极简SSH服务器,接近完成时遇到瓶颈:执行客户端请求中的命令后,通过StdoutPipe/StderrPipe获取输出并写入channel,但数据无法返回给客户端,客户端卡在session.Run处等待。已确认客户端功能正常(与标准OpenSSH服务器交互时所有命令均可成功执行),因此问题出在服务器代码中。

原服务器通道处理代码(精简版)

func handleChannel(newChannel ssh.NewChannel) {
    // Error out channels other than 'session'
    if newChannel.ChannelType() != "session" {
        logError("SSH channel error", fmt.Errorf("unauthorized channel type requested: %s", newChannel.ChannelType()), false)
        return
    }

    // Accept the channel
    channel, requests, err := newChannel.Accept()
    if err != nil {
        logError("SSH channel error", fmt.Errorf("could not accept channel: %v", err), false)
        return
    }
    defer channel.Close()

    fmt.Printf("DEBUG: Accepted new channel (type=%s)\n", newChannel.ChannelType())
    // Loop client requests - Only allow SFTP or Exec
    for req := range requests {
        fmt.Printf(" DEBUG: Received Request (type=%s)\n", req.Type)
        switch req.Type {
        case "exec":
            command, err := StripPayloadHeader(req.Payload)
            if err != nil {
                logError("SSH request error", fmt.Errorf("exec: failed to strip request payload header: %v", err), false)
                continue
            }
            if req.WantReply {
                fmt.Printf(" DEBUG: Sending (reply) confirmation after command request\n")
                req.Reply(true, nil)
            }
            err = executeCommand(channel, command)
            if err != nil {
                logError("SSH request error", fmt.Errorf("failed command execution: %v", err), false)
                continue
            }
            fmt.Printf(" DEBUG: Sending (reply) confirmation after command execution\n")
            req.Reply(true, nil)
        case "subsystem":
            subsystem, err := StripPayloadHeader(req.Payload)
            if err != nil {
                logError("SSH request error", fmt.Errorf("subsystem: failed to strip request payload header: %v", err), false)
                continue
            }
            if subsystem != "sftp" {
                req.Reply(false, nil)
                logError("SSH request error", fmt.Errorf("received unauthorized subsystem %s", subsystem), false)
                continue
            }
            if req.WantReply {
                fmt.Printf(" DEBUG: Sending (reply) confirmation after sftp request\n")
                req.Reply(true, nil)
            }
            fmt.Printf(" DEBUG: Starting SFTP server\n")
            err = HandleSFTP(channel)
            fmt.Printf(" DEBUG: Finished SFTP server\n")
            if err != nil {
                logError("SSH request error", fmt.Errorf("failed sftp: %v", err), false)
                continue
            }
            fmt.Printf(" DEBUG: Sending (reply) confirmation after sftp completion\n")
            req.Reply(true, nil)
        default:
            req.Reply(false, nil) // Reject unknown requests
        }
        fmt.Printf(" DEBUG: Finished Request (type=%s)\n", req.Type)
    }

    // Close the session
    fmt.Printf("Closing channel\n")
    channel.Close()
}
func executeCommand(channel ssh.Channel, receivedCommand string) error {
    // Parse command for exe and args
    args := strings.Fields(receivedCommand)

    // Prep command and args for execution
    cmd := exec.Command(args[0], args[1:]...)

    // Pipes for receiving cmd outs
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        return err
    }
    stderr, err := cmd.StderrPipe()
    if err != nil {
        return err
    }

    // Writer for cmd stdout/stderr into channel
    var wg sync.WaitGroup
    wg.Add(2)
    go func() {
        defer wg.Done()
        io.Copy(channel.Stderr(), stderr)
    }()
    go func() {
        defer wg.Done()
        io.Copy(channel, stdout)
    }()

    // Run command and wait for output
    fmt.Printf("  DEBUG: Running Command: %s\n", cmd)
    err = cmd.Run()
    if err != nil {
        return err
    }
    wg.Wait()

    fmt.Printf("  DEBUG: Channel Contents post command (string): %s\n  DEBUG: Channel bytes: %v\n", channel, channel)
    return nil
}

原客户端命令执行代码片段

func RunSSHCommand(client *ssh.Client, command string) (string, error) {
    // Open new session
    session, err := client.NewSession()
    if err != nil {
        return "", fmt.Errorf("failed to create session: %v", err)
    }
    defer session.Close()

    // Command output
    stdout, err := session.StdoutPipe()
    if err != nil {
        return "", fmt.Errorf("failed to get stdout pipe: %v", err)
    }
    // Command Error
    stderr, err := session.StderrPipe()
    if err != nil {
        return "", fmt.Errorf("failed to get stderr pipe: %v", err)
    }

    // Run the command
    fmt.Printf("DEBUG: Run command: %s\n", command)
    if err := session.Run(command); err != nil {
        return "", fmt.Errorf("failed to run command: %v", err)
    }

    fmt.Printf("DEBUG: Reading from stdout...\n")
    CommandOutput, err := io.ReadAll(stdout)
    fmt.Printf("DEBUG: Stdout from Channel: %s\n", stdout)
    fmt.Printf("DEBUG: Io ReadAll from stdout: %s\n", CommandOutput)
    if err != nil {
        return "", fmt.Errorf("error reading from io.Reader: %v", err)
    }

    CommandError, err := io.ReadAll(stderr)
    fmt.Printf("DEBUG: Command Err %s\n", CommandError)
    if err != nil {
        return "", fmt.Errorf("error reading from io.Reader: %v", err)
    }

    // Only return the error if there is one
    if string(CommandError) != "" {
        return string(CommandOutput), fmt.Errorf("%v", string(CommandError))
    }

    return string(CommandOutput), nil
}

我通过goroutine+io.Copy将stdout/stderr写入channel,并用WaitGroup确保输出写入后再关闭通道,但调试显示channel已有输出,客户端仍挂起。曾考虑使用ssh Reply函数,但文档说明“通道特定请求的回复会忽略payload”,无法采用该方式。

基于@LeGEC修复方案更新后的服务器代码片段

func handleChannel(newChannel ssh.NewChannel) {
    // Accept the channel
    channel, requests, err := newChannel.Accept()
    if err != nil {
        logError("SSH channel error", fmt.Errorf("could not accept channel: %v", err), false)
        return
    }
    defer channel.Close()

    // Loop client requests - Only allow SFTP or Exec
    for req := range requests {
        switch req.Type {
        case "exec":
            command, err := StripPayloadHeader(req.Payload)
            if err != nil {
                logError("SSH request error", fmt.Errorf("exec: failed to strip request payload header: %v", err), false)
                break
            }
            if req.WantReply {
                req.Reply(true, nil)
            }
            err = executeCommand(channel, command)
            if err != nil {
                logError("SSH request error", fmt.Errorf("failed command execution: %v", err), false)
                break
            }
        case "subsystem":
            subsystem, err := StripPayloadHeader(req.Payload)
            if err != nil {
                logError("SSH request error", fmt.Errorf("subsystem: failed to strip request payload header: %v", err), false)
                break
            }
            if subsystem != "sftp" {
                req.Reply(false, nil)
                logError("SSH request error", fmt.Errorf("received unauthorized subsystem %s", subsystem), false)
                break
            }
            if req.WantReply {
                req.Reply(true, nil)
            }
            // Handle SFTP
            err = HandleSFTP(channel)
            if err != nil {
                logError("SSH request error", fmt.Errorf("failed sftp: %v", err), false)
                break
            }
        default:
            req.Reply(false, nil) // Reject unknown requests

        }
        channel.Close()
    }
}

func executeCommand(channel ssh.Channel, receivedCommand string) error {
    // Parse command for exe and args
    commandArray := strings.Fields(receivedCommand)
    commandBinary := commandArray[0]

    // Prep command and args for execution
    cmd := exec.Command(commandBinary, commandArray[1:]...)

    // Init command output buffers
    var stdout, stderr bytes.Buffer
    cmd.Stdout = &stdout
    cmd.Stderr = &stderr

    // Run the command and set exit code
    err := cmd.Run()

    // Determine exit code to send back
    var exitCode int
    if err != nil {
        if exitError, ok := err.(*exec.ExitError); ok {
            // Command failed with a non-zero exit code
            exitCode = exitError.ExitCode()
        } else {
            if strings.Contains(err.Error(), "executable file not found in ") {
                exitCode = 127 // Command not found
                stderr.WriteString(err.Error())
            } else {
                exitCode = 126 // Command exists but cannot execute
                stderr.WriteString("Command exists but cannot execute\n")
            }
        }
    } else {
        exitCode = 0 // Command executed successfully
    }

    // Send command output back through channel
    io.Copy(channel, &stdout)
    io.Copy(channel.Stderr(), &stderr)

    // Send exit status back through channel
    exitStatus := make([]byte, 4)
    binary.BigEndian.PutUint32(exitStatus, uint32(exitCode))
    channel.SendRequest("exit-status", false, exitStatus)

    // Return any errors
    if err != nil {
        return err
    }
    return nil
}

客户端代码未改动。

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.06.18 05:45:55