Table of Contents

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: sudo that 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: Pending holds 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, ExpectAsync throws EndOfStreamException.
  • One reader. SshExpect reads StandardOutput itself. Do not also read it with a StreamReader; 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.ObscureKeystrokeTiming hides the timing of keys from someone watching the traffic. See Commands and shells.

See also