Migrating from SSH.NET
This guide maps SSH.NET code to SshSharp. Before you start, check that SshSharp covers what you
need (see "Why not SSH.NET?" in the README): it targets .NET 8 and later only, and it does not speak legacy algorithms
(CBC ciphers, SHA-1 MACs, ssh-rsa signatures, DH group 1).
The main differences:
- Async only. Every network operation is
asyncand takes aCancellationToken. Clients, sessions and forwarders areIAsyncDisposable, so useawait using. - Connecting is one call.
SshClient.ConnectAsyncreturns a client that is already connected and authenticated. There is no separate constructor,ConnectorDisconnect; disposing the client disconnects. - Options are immutable.
SshClientOptionsusesinitproperties, set once. It replacesConnectionInfo, the authentication method objects and the client properties. - Host keys are always verified.
HostKeyVerifieris required. There is no "accept anything" default and no event to forget to handle. - One connection for everything. Commands, shells, port forwarding and SCP all run over the same
SshClient. SSH.NET'sScpClientopens its own connection.
What you gain
Features that SSH.NET users have asked for in its issue tracker, and that SshSharp has:
| Request | SshSharp |
|---|---|
Verify host keys against known_hosts |
KnownHostsVerifier, with hashed hosts, @cert-authority, @revoked and StrictHostKeyChecking (see Host keys) |
| Jump hosts / bastions | ProxyJump chains and ProxyCommand from ssh_config, or by hand: OpenTcpTunnelAsync to the next host and SshClient.ConnectAsync(stream, ...) over it (see Connecting) |
Use the keys in ssh-agent; agent forwarding |
SshAgent and ForwardAgent, on Unix sockets, the Windows OpenSSH agent and PuTTY's Pageant (SshAgent.FromPageant()) |
Add, remove and lock agent keys (ssh-add) |
agent.AddKeyAsync(key, constraints) (lifetime, confirmation), RemoveKeyAsync, RemoveAllKeysAsync, LockAsync and UnlockAsync; AddKeysToAgent in ssh_config (see Authentication) |
A subsystem other than sftp |
client.StartSubsystemAsync(name) |
A terminal for a single command (ssh -t host cmd) |
StartCommandAsync(command, new SshSessionOptions { Terminal = ... }) |
| Send a signal to a command | session.SendSignalAsync("TERM") |
| Forward to a Unix socket | client.OpenUnixSocketTunnelAsync(path); the other way, the server's socket to this machine, with client.ForwardRemoteUnixSocketAsync(remotePath, ...) |
A SOCKS proxy on the server's side (ssh -R port) |
client.ForwardRemoteDynamicPortAsync(bindAddress, port, allowDestination) |
HttpClient through the connection |
client.CreateHttpConnectCallback() for SocketsHttpHandler.ConnectCallback |
Fetch host keys without logging in (ssh-keyscan) |
SshClient.ScanHostKeysAsync(host, port) |
Generate keys (ssh-keygen -t) |
SshPrivateKey.Generate(type) and SaveAsync(path, passphrase) |
Sign and verify files or git commits (ssh-keygen -Y) |
SshSignature and SshAllowedSigners (see Signing) |
| Connect from a chosen local address or interface | SshClientOptions.LocalEndPoint, BindAddress and BindInterface in ssh_config |
Agent keys limited to some hosts (ssh-add -h) |
adding them: SshAgentKeyConstraints.Destinations (restrict-destination-v00@openssh.com); using them: session binding and publickey-hostbound, automatically |
| Send a BREAK to a serial console | session.SendBreakAsync() (RFC 4335, as OpenSSH's ~B) |
| Revoked host keys | KnownHostsVerifier.RevokedHostKeys (RevokedHostKeys in ssh_config): OpenSSH KRLs from ssh-keygen -k, or key lists |
| Measure the round trip | client.PingAsync() (ping@openssh.com, or a keepalive@openssh.com request on older servers) |
| Close idle channels | SshClientOptions.ChannelTimeouts (ChannelTimeout in ssh_config, as OpenSSH 9.2+) |
| Faster SFTP downloads over high-latency links | SftpTransferOptions.Segments: one file in parallel ranges, each over its own SFTP session (see SFTP) |
| Kerberos / Active Directory sign-in (GSSAPI) | SshClientOptions.Gssapi, or GSSAPIAuthentication yes in ssh_config (.NET 10; see Authentication) |
Remote files as an IFileSystem or IFileProvider |
the SshSharp.IO.Abstractions and SshSharp.FileProviders packages |
| Detect a stalled connection, also while a write is blocked | keep-alive (KeepAliveInterval, KeepAliveCountMax): the probes don't wait for pending writes, so a server that stops reading is still detected |
| RSA SHA-2 signatures with servers that announce them | server-sig-algs (RFC 8308) is used to pick rsa-sha2-512 or rsa-sha2-256 |
| FIPS-approved algorithms only | SshClientOptions.FipsMode, including post-quantum key exchange on .NET 10 |
| Cancellation everywhere | a CancellationToken on every network operation |
Connecting
SSH.NET:
using var client = new SshClient("example.com", 22, "deploy", password);
client.HostKeyReceived += (_, e) => e.CanTrust = e.FingerPrintSHA256 == expectedFingerprint;
client.Connect();
SshSharp:
await using var client = await SshClient.ConnectAsync("example.com", 22, new SshClientOptions
{
UserName = "deploy",
Password = password,
HostKeyVerifier = SshHostKeyVerifier.Pinned("SHA256:" + expectedFingerprint),
});
FingerPrintSHA256 in SSH.NET is the base64 hash without a prefix. SshSharp fingerprints are written as ssh-keygen -l prints
them, with the SHA256: prefix.
Or connect the way ssh does, with ~/.ssh/config, known_hosts, the user's keys and the agent:
await using var client = await SshClient.ConnectAsync("deploy@example.com");
See Connecting for streams, jump hosts and timeouts.
Connection settings
| SSH.NET | SshSharp |
|---|---|
new SshClient(host, port, user, password) |
SshClient.ConnectAsync(host, port, new SshClientOptions { UserName, Password, HostKeyVerifier }) |
new SshClient(host, user, keyFiles) |
SshClientOptions.PrivateKeys (see Keys) |
new SshClient(connectionInfo) |
SshClientOptions; or ssh_config with SshClient.ConnectAsync(destination) |
client.Connect(), ConnectAsync(ct) |
SshClient.ConnectAsync(...): returns a connected client |
client.Disconnect(), Dispose() |
await client.DisposeAsync() |
ConnectionInfo.Timeout |
a CancellationToken (for example new CancellationTokenSource(TimeSpan.FromSeconds(10))), or ConnectTimeout in ssh_config |
client.KeepAliveInterval |
SshClientOptions.KeepAliveInterval and KeepAliveCountMax (ServerAliveInterval in ssh_config) |
ConnectionInfo.Encryptions / KeyExchangeAlgorithms / HostKeyAlgorithms (removing entries) |
Ciphers, KexAlgorithms, HostKeyAlgorithms in ssh_config (the defaults are modern only; SshClientOptions.FipsMode restricts them to FIPS algorithms) |
ConnectionInfo.ProxyType, ProxyHost, ProxyPort, ProxyUsername, ProxyPassword |
SshClientOptions.Proxy = SshProxy.Http(host, port, credentials) (or Socks5, Socks4, or SshProxy.FromUri(new Uri("socks5://proxy:1080"))) |
client.IsConnected |
client.IsConnected; client.ConnectionClosed (a CancellationToken) fires when the connection drops |
client.ErrorOccurred event |
client.ConnectionClosed and client.ConnectionError |
client.ConnectionInfo.ServerVersion |
client.ServerIdentification |
Host keys
| SSH.NET | SshSharp |
|---|---|
HostKeyReceived with e.CanTrust = e.FingerPrintSHA256 == ... |
SshHostKeyVerifier.Pinned("SHA256:...") |
HostKeyReceived with your own logic |
SshHostKeyVerifier.FromDelegate((context, ct) => ...); context.HostKey has the key, type and fingerprint |
| (no handler: every key is accepted) | no equivalent: a verifier is required |
(no known_hosts support) |
KnownHostsVerifier.ForCurrentUser(StrictHostKeyChecking.Yes) |
A rejected key throws SshHostKeyException, which carries the key the server presented. See Host keys.
Authentication
| SSH.NET | SshSharp |
|---|---|
PasswordAuthenticationMethod(user, password) |
SshClientOptions.Password |
PrivateKeyAuthenticationMethod(user, keyFiles) |
SshClientOptions.PrivateKeys |
KeyboardInteractiveAuthenticationMethod and its AuthenticationPrompt event |
SshClientOptions.KeyboardInteractive, a callback that returns the answers |
several methods in ConnectionInfo |
set several options: the client tries public keys, keyboard-interactive, then password, as ssh does, and follows the server's multi-factor chains |
| (no agent support) | SshAgent.FromEnvironment() and GetKeysAsync(); ForwardAgent for agent forwarding |
SshAuthenticationException |
SshAuthenticationException, with AllowedMethods listing what the server would accept |
When the server takes passwords only through keyboard-interactive (PAM with PasswordAuthentication no), Password answers that
prompt, so you need no handler for it.
Keys
| SSH.NET | SshSharp |
|---|---|
new PrivateKeyFile(path), new PrivateKeyFile(path, passphrase) |
await SshPrivateKey.LoadAsync(path, passphrase) |
new PrivateKeyFile(stream) |
SshPrivateKey.Parse(text, passphrase) |
OpenSSH, PKCS#1, PKCS#8, PuTTY .ppk |
the same, plus SEC1; ssh.com keys are not read (convert them with ssh-keygen -i) |
ssh-rsa (SHA-1) signatures |
RSA keys sign with rsa-sha2-256 or rsa-sha2-512 only |
| (no key generation) | SshPrivateKey.Generate(SshKeyType.Ed25519), then SaveAsync(path, passphrase) (see Authentication) |
SshPrivateKey is IDisposable: disposing it clears the key material.
Commands
SSH.NET:
var command = client.RunCommand("df -h /");
Console.WriteLine($"exit {command.ExitStatus}: {command.Result}");
SshSharp:
var result = await client.RunCommandAsync("df -h /");
Console.WriteLine($"exit {result.ExitStatus}: {result.StandardOutputText}");
| SSH.NET | SshSharp |
|---|---|
client.RunCommand(text), command.ExecuteAsync(ct) |
await client.RunCommandAsync(text, ct) |
command.Result, command.Error |
result.StandardOutputText, result.StandardErrorText (or the raw bytes: StandardOutput, StandardError) |
command.ExitStatus, command.ExitSignal |
result.ExitStatus, result.ExitSignal?.Name |
command.OutputStream, ExtendedOutputStream, CreateInputStream() |
await client.StartCommandAsync(text), then session.StandardOutput, StandardError, StandardInput |
command.CancelAsync() |
cancel the token, or session.SendSignalAsync("TERM") |
command.CommandTimeout |
a CancellationToken |
RunCommandAsync collects all output in memory, up to SshClientOptions.MaxCommandOutputBytes (a safety limit). For large or
endless output, stream it:
await using var session = await client.StartCommandAsync("tail -f /var/log/syslog");
using var reader = new StreamReader(session.StandardOutput);
while (await reader.ReadLineAsync(cancellationToken) is { } line)
{
Console.WriteLine(line);
}
Shells
SSH.NET:
using var shell = client.CreateShellStream("xterm", 80, 24, 0, 0, 4096);
shell.WriteLine("uptime");
var line = shell.ReadLine();
SshSharp offers three ways, depending on what the shell is for.
Option 1: one command, no shell. Often the shell only runs a command and reads its output. A command needs no terminal, so there is no echo or prompt to skip, and you get the exit status too:
var result = await client.RunCommandAsync("uptime");
var line = result.StandardOutputText.TrimEnd('\n');
Option 2: an interactive shell, line by line, with SshExpect. It decodes the output and keeps a buffer, so lines, prompts and
partial reads mix safely:
await using var shell = await client.StartShellAsync(new SshSessionOptions
{
Terminal = new SshTerminalOptions { TerminalType = "xterm", Columns = 80, Rows = 24 },
});
var io = new SshExpect(shell);
await io.SendLineAsync("uptime");
var echo = await io.ReadLineAsync(); // the terminal echoes the command first, as with ShellStream
var line = await io.ReadLineAsync();
A prompt ends without a line feed, so wait for it with ExpectAsync (below) rather than ReadLineAsync, which would wait for the
line to end.
Option 3: the session's streams, for full control. StandardInput and StandardOutput are plain streams, for your own reader and
writer, or to copy to a file or the console:
await using var shell = await client.StartShellAsync(new SshSessionOptions
{
Terminal = new SshTerminalOptions { TerminalType = "xterm", Columns = 80, Rows = 24 },
});
var input = new StreamWriter(shell.StandardInput) { AutoFlush = true };
var output = new StreamReader(shell.StandardOutput);
await input.WriteLineAsync("uptime");
var line = await output.ReadLineAsync();
Use one way per session: SshExpect and a StreamReader both buffer what they read, so reading the same output with both loses
data.
For ShellStream.Expect, wrap the session in SshExpect:
| SSH.NET | SshSharp |
|---|---|
shell.Expect("$ ", timeout) |
await expect.ExpectAsync("$ ", token): returns Before and Value; throws EndOfStreamException when the output ends first |
shell.Expect(new ExpectAction(regex, action)) |
var match = await expect.ExpectAsync(regex, token), then act on match.Match.Groups |
shell.WriteLine(text) |
await expect.SendLineAsync(text, token) |
Create it with var expect = new SshExpect(shell). Resize with shell.ResizeTerminalAsync(columns, rows). See
Commands and shells.
Port forwarding
SSH.NET:
var forward = new ForwardedPortLocal("127.0.0.1", 15432, "db.internal", 5432);
client.AddForwardedPort(forward);
forward.Start();
SshSharp:
await using var forward = await client.ForwardLocalPortAsync(new IPEndPoint(IPAddress.Loopback, 15432), "db.internal", 5432);
| SSH.NET | SshSharp |
|---|---|
ForwardedPortLocal (ssh -L) |
client.ForwardLocalPortAsync(localEndPoint, remoteHost, remotePort) |
ForwardedPortRemote (ssh -R) |
client.ForwardRemotePortAsync(bindAddress, remotePort, localHost, localPort) |
| (no remote dynamic forwarding) | client.ForwardRemoteDynamicPortAsync(bindAddress, remotePort, allowDestination) (ssh -R port) |
ForwardedPortDynamic (ssh -D) |
client.ForwardDynamicPortAsync(localEndPoint) |
forward.Start(), Stop() |
started when returned; stopped by DisposeAsync |
| (no remote Unix-socket forwarding) | client.ForwardRemoteUnixSocketAsync(remoteSocketPath, localHost, localPort) or (remoteSocketPath, localSocketPath) (ssh -R /socket:target) |
client.ForwardedPorts |
client.LocalForwardings, RemoteForwardings, RemoteSocketForwardings, DynamicForwardings (the ones started from ssh_config) |
| (none) | client.OpenTcpTunnelAsync(host, port) and OpenUnixSocketTunnelAsync(path): a Stream to the far end, without a local listener |
With ssh_config, LocalForward, RemoteForward and DynamicForward start with the connection when
SshConfigConnectOptions.StartForwardings is set.
SFTP
SSH.NET:
using var sftp = new SftpClient("files.example.com", "deploy", password);
sftp.Connect();
using var file = File.OpenRead("report.pdf");
sftp.UploadFile(file, "incoming/report.pdf");
SshSharp (see File transfer (SFTP)):
using SshSharp.Sftp;
await using var sftp = await client.OpenSftpAsync();
await sftp.UploadFileAsync("report.pdf", "incoming/report.pdf");
| SSH.NET | SshSharp (using SshSharp.Sftp;) |
|---|---|
new SftpClient(...), Connect() |
await client.OpenSftpAsync() on a connected SshClient (one connection for SFTP, commands and forwarding) |
UploadFile(stream, path), UploadFileAsync |
UploadAsync(stream, path), or UploadFileAsync(localPath, path) |
DownloadFile(path, stream), DownloadFileAsync |
DownloadAsync(path, stream), or DownloadFileAsync(path, localPath) |
ListDirectory(path), ListDirectoryAsync |
ListDirectoryAsync(path): IAsyncEnumerable<SftpFileInfo>, without . and .. |
Exists, Get, GetAttributes |
ExistsAsync, GetAttributesAsync |
CreateDirectory, DeleteDirectory, DeleteFile, Delete |
CreateDirectoryAsync, DeleteDirectoryAsync, DeleteFileAsync |
RenameFile(old, new, isPosix) |
RenameAsync(old, new, overwrite) |
Open, OpenRead, OpenWrite, Create, AppendText |
OpenAsync(path, FileMode, FileAccess), OpenReadAsync, OpenWriteAsync |
ChangePermissions, SetLastWriteTime, SetAttributes |
SetAttributesAsync(path, new SftpFileAttributes { Permissions = ..., LastWriteTime = ... }) |
SymbolicLink, ReadLink-style calls |
CreateSymbolicLinkAsync, ReadSymbolicLinkAsync; also CreateHardLinkAsync |
WorkingDirectory, ChangeDirectory |
none: pass full paths, or paths relative to the login directory (GetHomeDirectoryAsync) |
SftpPathNotFoundException, SftpPermissionDeniedException |
SftpException with Status NoSuchFile, PermissionDenied (and Path) |
| upload/download callbacks | SftpTransferOptions.Progress |
BufferSize, MaxPendingReads |
taken from the server's limits; SftpTransferOptions.MaxRequestsInFlight |
SCP
SSH.NET:
using var scp = new ScpClient("example.com", "deploy", password);
scp.Connect();
scp.Upload(new FileInfo("site.tar.gz"), "/tmp/site.tar.gz");
SshSharp:
using SshSharp.Scp;
await client.UploadFileAsync("site.tar.gz", "/tmp/site.tar.gz");
| SSH.NET | SshSharp (using SshSharp.Scp;) |
|---|---|
scp.Upload(Stream, path) |
client.UploadAsync(stream, remotePath) |
scp.Upload(FileInfo, path) |
client.UploadFileAsync(localPath, remotePath) |
scp.Upload(DirectoryInfo, path) |
client.UploadDirectoryAsync(localDirectory, remotePath) |
scp.Download(path, Stream) |
client.DownloadAsync(remotePath, stream) |
scp.Download(path, FileInfo) / DirectoryInfo |
client.DownloadFileAsync(...) / DownloadDirectoryAsync(...) |
Uploading / Downloading events |
SshScpOptions.Progress |
RemotePathTransformation |
not needed: remote paths are always quoted as one word for the server's shell |
See File transfer (SCP).
Errors
| SSH.NET | SshSharp |
|---|---|
SshConnectionException |
SshException with Reason (ConnectFailed, ConnectionClosed, ProtocolError, KeyExchangeFailed, ...) |
SshAuthenticationException |
SshAuthenticationException (a subclass of SshException) |
SshOperationTimeoutException |
OperationCanceledException when your token was cancelled; SshException with Reason = Timeout for ConnectTimeout |
ProxyException |
SshException with Reason = ConnectFailed, saying what the proxy answered |
ScpException |
SshException with Reason = TransferFailed |
See Errors and diagnostics for logging (SshClientOptions.Logger), tracing and metrics.
See also
- Getting started and the how-to guides
- FAQ
- Unit-test code that uses SshSharp: SshSharp has no interfaces to mock, by design