Execute a command
In the previous chapters, we connected to the server and authenticated ourselves, so now we can finally execute some commands!
Session
A single SSH connection can host multiple logical channels of communication. The SSH protocol defines two kinds of channels: interactive sessions and TCP/IP forwarding channels (or tunnels). In this chapter, we will learn how to use sessions to execute commands, and the next chapter will be about tunnels.
One session corresponds to one process: you open a session, prepare the execution environment (such as environment variables), start the command or shell, and then interact with it.
Open a session
To open a session, we use the method Client::open_session()
(after we have authenticated successfully). To configure the underlying channel, this method needs a ChannelConfig
. You can adjust the configuration if you need to optimize the SSH flow control, but the default instance should work well for most use cases:
// Open a session on the server.
let channel_config = makiko::ChannelConfig::default();
let (session, mut session_rx) = client.open_session(channel_config).await
.expect("Could not open a session");
The open_session()
method returns two objects, a Session
and a SessionReceiver
. This is the same pattern as with Client
and ClientReceiver
: you use the Session
object to invoke operations on the session, and the SessionReceiver
object to receive events from the session.
Handle session events
To handle events from the SessionReceiver
, we will spawn another task, like we did with client events. To receive the events, we will use the method SessionReceiver::recv()
. The events are represented using the enum SessionEvent
. The recv()
method returns None
when the session is closed and no more events will be received:
tokio::task::spawn(async move {
loop {
// Wait for the next event.
let event = session_rx.recv().await
.expect("Error while receiving session event");
// Exit the loop when the session has closed.
let Some(event) = event else {
break
};
match event {
... // We will handle the event here
}
}
});
You have to receive the events from the SessionReceiver
even if you don’t need to handle them (which should be rare). Makiko internally uses a channel to send events to the SessionReceiver
, and if you don’t receive the events, this channel will become full and the client will block.
Output from the process
Output from the process is received as StdoutData
and StderrData
variants of SessionEvent
:
match event {
// Handle stdout/stderr output from the process.
makiko::SessionEvent::StdoutData(data) => {
println!("Process produced stdout: {:?}", data);
},
makiko::SessionEvent::StderrData(data) => {
println!("Process produced stderr: {:?}", data);
},
...
}
The data is received as chunks of bytes, but the boundaries between the chunks are not meaningful, you should treat stdout and stderr as byte streams.
Process exit
When the process exits, the SSH server sends an ExitStatus
if the process exited with a status, or ExitSignal
if it was killed by a signal:
match event {
...
// Handle exit of the process.
makiko::SessionEvent::ExitStatus(status) => {
println!("Process exited with status {}", status);
},
makiko::SessionEvent::ExitSignal(signal) => {
println!("Process exited with signal {:?}: {:?}", signal.signal_name, signal.message);
},
...
}
Other events
The server may also send an Eof
event after the process closes its stdout and stderr. We will ignore this event, together with any other events that might be introduced in future versions of the library:
match event {
...
// Ignore other events
_ => {},
}
Note that the SessionEvent
enum is marked as #[non_exhaustive]
, so the Rust compiler will require you to add the catch-all match
clause even if you handle all variants of the enum. This allows us to add new kinds of events to Makiko without breaking your code.
Execute the command
The session is now ready, so we can execute the command using Session::exec()
. We will execute the command sed s/blue/green/g
, which reads lines from the standard input, replaces blue
with green
, and prints the lines back to stdout:
// Execute a command on the session
session.exec("sed s/blue/green/".as_bytes())
.expect("Could not execute a command in the session")
.wait().await
.expect("Server returned an error when we tried to execute a command in the session");
The exec()
method returns a SessionResp
, which represents the server response to the execute request. We wait for the response using SessionResp::wait()
, but you can also ignore the response with SessionResp::ignore()
Send data to the process
We will use the Session::send_stdin()
method to send data to the standard input of the running process, and Session::send_eof()
to send end-of-file, which will close the standard input:
// Send some data to the standard input of the process
session.send_stdin("blueberry jam\n".into()).await.unwrap();
session.send_stdin("blue jeans\nsky blue".into()).await.unwrap();
session.send_eof().await.unwrap();
Wait for the session
We have started the process and sent some data to it, and now we need to wait until the process terminates and the session is closed. Recall that when the session is closed, the SessionReceiver
returns None
, we break out of the event handling loop and the task terminates. We will change the code that we have written previously to store the JoinHandle
from the spawn()
call:
let session_event_task = tokio::task::spawn(async move {
loop {
let event = ...;
}
});
Back on the main task, we will wait for the event-handling task to terminate:
// Wait for the task that handles session events
session_event_task.await.unwrap();
Full code for this tutorial can be found in examples/tutorial_5.rs
. If you don’t use the example server for this tutorial, you may need to change the code to use a different username and password.