Interactive shells and SshExpect
SshClient.StartShellAsync starts the user's login shell, usually with a pseudo-terminal, as ssh host does. SshExpect scripts
such a session the way the expect tool does: wait for a prompt or other output, then send the next line.
When to use
- The program only works interactively:
sudothat asks for a password, an installer that asks questions,passwd. - The server is a network device or appliance whose CLI is not a POSIX shell (routers, switches, storage arrays).
- You relay a terminal to a person, as a web terminal does.
When a plain command would do, prefer RunCommandAsync: no prompt to wait for, no echo to skip, and you get
the exit status.
A shell with a terminal
await using var shell = await client.StartShellAsync(new SshSessionOptions
{
Terminal = new SshTerminalOptions { TerminalType = "xterm-256color", Columns = 120, Rows = 40 },
Environment = new Dictionary<string, string> { ["LANG"] = "C.UTF-8" }, // only if sshd's AcceptEnv allows it
}, cancellationToken);
// Relay the shell to the console until it exits.
var output = shell.StandardOutput.CopyToAsync(Console.OpenStandardOutput(), cancellationToken);
await shell.StandardInput.WriteAsync("uname -a; exit\n"u8.ToArray(), cancellationToken);
await output;
With a terminal, the server merges stderr into stdout and echoes your input. Resize it when the local window changes, and send control characters as a user's keyboard would:
await shell.ResizeTerminalAsync(columns: Console.WindowWidth, rows: Console.WindowHeight, cancellationToken: cancellationToken);
await shell.StandardInput.WriteAsync(new byte[] { 0x03 }, cancellationToken); // Ctrl-C: interrupt the foreground program
Answer a prompt with SshExpect
ExpectAsync waits until the output contains a text or matches a regular expression, and returns what came before it (Before),
the match (Value) and, for a regular expression, its groups (Match). Output after the match is kept for the next call.
// The password comes from the environment (or a secret store), never from source code.
var sudoPassword = Environment.GetEnvironmentVariable("SUDO_PASSWORD")
?? throw new InvalidOperationException("Set SUDO_PASSWORD.");
var prompt = new Regex(@"[$#] $", RegexOptions.None, TimeSpan.FromSeconds(1));
await using var shell = await client.StartShellAsync(
new SshSessionOptions { Terminal = new SshTerminalOptions { TerminalType = "dumb" } }, cancellationToken);
var expect = new SshExpect(shell);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30)); // the whole conversation, not each step
await expect.ExpectAsync(prompt, timeout.Token); // the first prompt
await expect.SendLineAsync("sudo systemctl restart myapp", timeout.Token);
await expect.ExpectAsync("password", timeout.Token); // "[sudo] password for user:"
await expect.SendLineAsync(sudoPassword, timeout.Token);
var done = await expect.ExpectAsync(prompt, timeout.Token);
Console.WriteLine(done.Before); // what the command printed
await expect.SendLineAsync("exit", timeout.Token);
Read output line by line
ReadLineAsync reads from the same buffer, so lines and prompts mix. A prompt has no line feed after it, so wait for prompts with
ExpectAsync and use a marker line to find the end of a command's output:
// A marker line ends the output, since the prompt that follows has no line feed.
await expect.SendLineAsync("ls -1 /etc/ssh; echo '--end--'", cancellationToken);
await expect.ReadLineAsync(cancellationToken); // the terminal's echo of the command
while (await expect.ReadLineAsync(cancellationToken) is { } line && line != "--end--")
{
Console.WriteLine(line);
}
Capture values with groups
await expect.SendLineAsync("df -P / | tail -1", cancellationToken);
var match = await expect.ExpectAsync(new Regex(@"(\d+)%\s+/\r?\n", RegexOptions.None, TimeSpan.FromSeconds(1)), cancellationToken);
var usedPercent = int.Parse(match.Match!.Groups[1].Value, System.Globalization.CultureInfo.InvariantCulture);
Script a network device
// A router or switch CLI: no POSIX shell, so commands go through the interactive session.
await using var cli = await client.StartShellAsync(
new SshSessionOptions { Terminal = new SshTerminalOptions { TerminalType = "vt100", Columns = 200, Rows = 50 } }, cancellationToken);
var expect = new SshExpect(cli);
var prompt = new Regex(@"[>#]\s*$", RegexOptions.None, TimeSpan.FromSeconds(1));
await expect.ExpectAsync(prompt, cancellationToken);
await expect.SendLineAsync("terminal length 0", cancellationToken); // no --More-- paging
await expect.ExpectAsync(prompt, cancellationToken);
await expect.SendLineAsync("show version", cancellationToken);
var version = await expect.ExpectAsync(prompt, cancellationToken);
Console.WriteLine(version.Before);
Start a subsystem
A subsystem is a protocol the server runs by name instead of a command, as ssh -s does: netconf, or anything configured with
sshd's Subsystem. The session's streams then carry that protocol byte for byte. (For SFTP use client.OpenSftpAsync().)
await using var netconf = await client.StartSubsystemAsync("netconf", cancellationToken: cancellationToken);
await netconf.StandardInput.WriteAsync(helloMessage, cancellationToken);
// ... then read the server's hello from netconf.StandardOutput, framed as NETCONF frames it.
Notes and pitfalls
- Timeouts are cancellation tokens. Give the whole conversation one deadline (
CancelAfter), as above. If the token is cancelled nothing is lost:Pendingholds what has arrived. - The echo matches too. With a terminal your input is echoed back, so a pattern can match the command you just sent. Wait for something the echo does not contain, such as the prompt that follows.
- Anchor patterns. Patterns are tried on output received so far:
\d+could match the first digits of a number still arriving. Give regular expressions a match timeout, as above, when the output is not yours. - End of output. If the session ends before a match,
ExpectAsyncthrowsEndOfStreamException. - One reader.
SshExpectreadsStandardOutputitself. Do not also read it with aStreamReader; both buffer, so each would lose data the other read. - Secrets. Prompts and output come from the server; strip control characters before showing them in a terminal, and never log what you send in answer to a password prompt.
- Keystroke timing. For applications that relay a person's typing,
SshClientOptions.ObscureKeystrokeTiminghides the timing of keys from someone watching the traffic. See Commands and shells.
See also
- Commands and shells (in-depth guide, including terminal modes, signals and BREAK)
- Migrate from SSH.NET: shells
- SshExpect and SshTerminalOptions in the API reference