Table of Contents

Use ssh_config and known_hosts

SshSharp reads OpenSSH's ssh_config and known_hosts with the same semantics as ssh. Connecting by alias applies everything the configuration says; the SshSharp.Config types also read the files directly, for tools that only want the values.

When to use

  • Your users already have working ~/.ssh/config entries, and your tool should behave like ssh with them.
  • Operations teams manage host names, users, keys, bastions and host key policy in configuration, not in code.
  • You need to inspect or update known_hosts yourself.

Connect by alias

// Everything comes from ~/.ssh/config: HostName, User, IdentityFile, ProxyJump, known_hosts, ...
await using var client = await SshClient.ConnectAsync("prod-db", cancellationToken: cancellationToken);

// The other destination forms ssh accepts.
await using var other = await SshClient.ConnectAsync("deploy@build.example.com", cancellationToken: cancellationToken);
await using var third = await SshClient.ConnectAsync("ssh://deploy@build.example.com:2222", cancellationToken: cancellationToken);

What the configuration decides: HostName, Port, User, IdentityFile, CertificateFile, the agent (IdentityAgent, IdentitiesOnly), known_hosts files and StrictHostKeyChecking, algorithms, ProxyJump and ProxyCommand, keep-alive, ConnectTimeout, and more; the ssh_config reference lists every directive and how it is used.

Supply what a configuration file cannot

Passphrases, answers to prompts, and policy for things a file should not decide alone go in SshConfigConnectOptions:

await using var client = await SshClient.ConnectAsync("prod-db", new SshConfigConnectOptions
{
    // Encrypted IdentityFile keys: the passphrase from a secret store or the environment, never from source code.
    PassphraseProvider = (path, ct) =>
        ValueTask.FromResult(Environment.GetEnvironmentVariable("SSH_KEY_PASSPHRASE")),
    // StrictHostKeyChecking ask: decide about an unknown host key.
    AskHostKey = (prompt, ct) =>
        ValueTask.FromResult(Confirm($"Trust {prompt.Context.HostKey.KeyType} {prompt.Context.HostKey.Fingerprint}?")),
    // A configuration that names a ProxyCommand runs a local program: refuse it unless the file is trusted.
    AllowProxyCommand = false,
}, cancellationToken);

Ship a configuration with your application

SshConfig.Parse (or SshConfig.Load for a file) gives the application its own configuration, independent of the user's:

// A configuration that ships with the application, instead of the user's ~/.ssh/config.
var config = SshConfig.Parse("""
    Host bastion
      HostName bastion.example.com
      User jump
      IdentityFile ~/.ssh/fleet_ed25519

    Host app-*
      ProxyJump bastion
      User deploy
      IdentityFile ~/.ssh/fleet_ed25519
      UserKnownHostsFile /etc/myapp/known_hosts
      StrictHostKeyChecking yes
      ServerAliveInterval 30
    """);

await using var client = await SshClient.ConnectAsync("app-3", new SshConfigConnectOptions { Config = config }, cancellationToken);

Read the configuration without connecting

Resolve returns what ssh -G would print for a host: Host and Match blocks, Include, tokens and environment variables applied.

var config = SshConfig.LoadDefault();                 // ~/.ssh/config, then the system-wide file
SshHostConfig host = config.Resolve("prod-db");        // what ssh -G prod-db prints
Console.WriteLine($"{host.User}@{host.HostName}:{host.Port}");
Console.WriteLine($"via {string.Join(" -> ", host.ProxyJump)}");
Console.WriteLine($"keys {string.Join(", ", host.IdentityFiles)}");
Console.WriteLine($"StrictHostKeyChecking {host.StrictHostKeyChecking}");

The verifier ssh would use for that host comes from the same resolution:

// The verifier ssh would use for this host: its known_hosts files, StrictHostKeyChecking, HashKnownHosts, RevokedHostKeys ...
var host = SshConfig.LoadDefault().Resolve("prod-db");
var verifier = KnownHostsVerifier.FromConfig(host);

Read and update known_hosts

var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".ssh", "known_hosts");
var knownHosts = await KnownHostsFile.LoadAsync(path, cancellationToken);

switch (knownHosts.Check("example.com", 22, client.HostKey.KeyType, client.HostKey.PublicKey.Span))
{
    case KnownHostsCheckResult.Match:
        Console.WriteLine("known");
        break;
    case KnownHostsCheckResult.Unknown:
        await KnownHostsFile.AppendAsync(path, "example.com", 22, client.HostKey.KeyType, client.HostKey.PublicKey, hash: true,
            cancellationToken: cancellationToken);
        break;
    case KnownHostsCheckResult.Changed or KnownHostsCheckResult.Revoked:
        throw new InvalidOperationException("The host key does not match known_hosts.");
}

Hashed host names (|1|salt|hash), [host]:port, wildcards and negation, @cert-authority and @revoked are understood. AppendAsync only appends, so comments and the order of existing lines are kept.

Notes and pitfalls

  • Commands in configuration files. ProxyCommand runs a local program (allowed by default, as in ssh; turn it off with AllowProxyCommand = false). KnownHostsCommand decides which host keys are trusted, so it runs only with AllowKnownHostsCommand = true. Match exec runs only when SshConfigResolveOptions.ExecMatcher is set. Only enable these for configuration files you trust.
  • Match exec and canonicalization are asynchronous. A configuration that needs them must be resolved with ResolveAsync; Resolve throws NotSupportedException. Connecting by alias always resolves asynchronously.
  • BatchMode yes turns off every prompt: AskHostKey, ConfirmHostKeyUpdate, PassphraseProvider and the keyboard-interactive handler are not used.
  • Session settings are yours to apply. SendEnv, SetEnv and RequestTTY are resolved (SshHostConfig.SendEnv, SetEnv, RequestTty) but not applied by connecting, since sessions are started by your code: pass them in SshSessionOptions.
  • Which file. SshConfig.LoadDefault() reads ~/.ssh/config, then the system file; SshConfigConnectOptions.Config replaces both.

See also