gosshchannel

GO SSH Server - How to write stdout/stderr back to ssh client?


I am writing a extremely barebones SSH server (to complement my go SSH client) and I am almost complete, except I ran in to a wall.

After running the command the client sends in its request payload, and getting the StdoutPipe/StderrPipe, it seems that writing those to the channel doesn't send it back to the client (the client just hangs waiting on its session.Run). I know the client works, because all of its commands succeed with a normal OpenSSH server, so I must be doing something wrong in the server code.

Server Code (abridged to the channel handling sections):

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
}

Snippet of client command code:

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
}

So, I get the two pipes ready, start an io.copy go routine for each stdout/stderr that will write the pipe contents into the channel, and then run the command (I have the waitgroup there to ensure the output gets written to the channel before the channel gets closed).

I threw the prints in there to see if maybe the writing wasn't occuring, but the output shows that the command stdout is present in the channel, yet the client is still hanging on its session.Run.

This is a test run of the server side:

DEBUG: Accepted new channel (type=session)
 DEBUG: Received Request (type=exec)
 DEBUG: Sending (reply) confirmation after command request
  DEBUG: Running Command: /usr/bin/sha256sum /etc/rsyslog.conf
  DEBUG: Channel Contents post command (string): &{session  %!s(uint32=0) %!s(uint32=0) %!s(uint32=32768) %!s(uint32=32768) %!s(*ssh.mux=&{0xc0000ba9c0 {{0 0} [0xc000122240] 0} 0xc000186000 {0 0} 0xc000186070 0xc0001860e0 0xc000070080 <nil>}) %!s(bool=true) %!s(ssh.channelDirection=0) %!s(chan interface {}=0xc00011e3f0) {%!s(int32=0) %!s(uint32=0)} %!s(chan *ssh.Request=0xc00011e380) %!s(bool=false) {%!s(*sync.Cond=&{{} 0xc000096d30 {0 0 0 <nil> <nil>} 824634410864}) %!s(uint32=2097068) %!s(int=0) %!s(bool=false)} %!s(*ssh.buffer=&{0xc0000a8780 0xc0000b4ba0 0xc0000b4ba0 true}) %!s(*ssh.buffer=&{0xc0000a87c0 0xc0000b4be0 0xc0000b4be0 true}) {%!s(int32=0) %!s(uint32=0)} %!s(uint32=2097152) %!s(uint32=0) {%!s(int32=0) %!s(uint32=0)} %!s(bool=false) map[%!s(uint32=0):^Tf55121cb4505fb91348d412abeceb8845f876b784628f03843f0c3213ca22766  /etc/rsyslog.conf
]}
  DEBUG: Channel bytes: &{session [] 0 0 32768 32768 0xc000186150 true 0 0xc00011e3f0 {0 0} 0xc00011e380 false {0xc0000a8740 2097068 0 false} 0xc0000b4bc0 0xc0000b4c00 {0 0} 2097152 0 {0 0} false map[0:[94 0 0 0 0 0 0 0 84 102 53 53 49 50 49 99 98 52 53 48 53 102 98 57 49 51 52 56 100 52 49 50 97 98 101 99 101 98 56 56 52 53 102 56 55 54 98 55 56 52 54 50 56 102 48 51 56 52 51 102 48 99 51 50 49 51 99 97 50 50 55 54 54 32 32 47 101 116 99 47 114 115 121 115 108 111 103 46 99 111 110 102 10]]}
 DEBUG: Sending (reply) confirmation after command execution
 DEBUG: Finished Request (type=exec)

And the debug on the client side hits DEBUG: Run command: sha256sum /etc/rsyslog.conf and just hangs there.

I would assume that writing to the channel would return the data back to the client, but now I am sure I'm missing something here.

I did think that maybe the ssh Reply function would be used here, since it takes a payload. But the documentation says: The payload argument is ignored for replies to channel-specific requests. Which the request I got from the client is firmly in this single channel (for this loop iteration), so I am fairly sure I can't use Reply.

Which brings me back to writing into the channel. I am not sure why the contents (which are clearly present in the channel) are not being sent back to the client?

Updated code based on @LeGEC fix. Server snippet:

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
}

Client is unchanged


Solution

  • In the code you posted: none of the actors (client or server) calls .Close(), and both run actions which wait on the other side to finish -- io.ReadAll on the client side, for req := range requests { on the server side.


    One of the two sides should indicate that it has completed.

    In a situation where the client initiates a session, and tries to run 1 or 2 or n commands, I would look into how the client can't detect that a command has completed, and to close the session once he has completed it's list of commands.