ASP.NET Core and dependency injection
SshSharp.Extensions.DependencyInjection registers SSH endpoints with Microsoft.Extensions.DependencyInjection, the way
AddHttpClient registers HTTP clients: named endpoints bound from configuration, a factory for new connections, one shared
connection per endpoint that is opened again when it drops, and a health check.
dotnet add package SshSharp.Extensions.DependencyInjection
When to use
- ASP.NET Core applications, worker services, and anything built on the generic host.
- Several components talk to the same servers, and the server details belong in configuration, not code.
How it fits together
flowchart TB
config["Configuration<br/>Ssh:Deploy section,<br/>secrets, environment"] --> options["SshEndpointOptions<br/>named 'deploy'<br/>(validated)"]
options --> factory["ISshClientFactory<br/>ConnectAsync('deploy')"]
factory -->|"a new connection<br/>per call: caller disposes"| job["A job service"]
factory --> provider["ISshConnectionProvider<br/>GetClientAsync('deploy')"]
provider -->|"one shared connection,<br/>reopened when it drops"| api["Endpoints and services"]
provider --> health["Health check<br/>AddSsh('deploy'): PingAsync"]
Register endpoints
// Program.cs
builder.Services.AddSshClient("deploy")
.BindConfiguration("Ssh:Deploy") // appsettings.json, environment variables, user secrets, Key Vault ...
.ValidateOnStart(); // a missing Host or UserName fails at startup, not on the first request
builder.Services.AddSshClient("db", o => o.ConfigAlias = "prod-db"); // everything from ~/.ssh/config
builder.Services.AddHealthChecks().AddSsh("deploy", tags: ["ready"]);
{
"Ssh": {
"Deploy": {
"Host": "deploy.example.com",
"UserName": "deploy",
"PrivateKeyFiles": [ "~/.ssh/deploy_ed25519" ],
"KnownHostsFiles": [ "/etc/myapp/known_hosts" ],
"KeepAliveInterval": "00:00:30"
}
}
}
Keep secrets out of appsettings.json: bind Password or PrivateKeyPassphrase from user secrets in development
(dotnet user-secrets set "Ssh:Deploy:Password" ...) and from environment variables or a secret store in production.
Use the shared connection
Most code should use ISshConnectionProvider: one connection per endpoint, shared by every caller (SSH runs many channels over one
connection at once), opened on first use and again after it drops.
/// <summary>A service that runs many short commands: the shared connection, opened once and reopened when it drops.</summary>
public sealed class ServerStatus(ISshConnectionProvider connections)
{
public async Task<string> LoadAverageAsync(CancellationToken cancellationToken)
{
var client = await connections.GetClientAsync("deploy", cancellationToken); // owned by the provider
return (await client.RunCommandAsync("cat /proc/loadavg", cancellationToken)).StandardOutputText.Trim();
}
}
app.MapGet("/uptime", async (ISshConnectionProvider connections, CancellationToken cancellationToken) =>
{
var client = await connections.GetClientAsync("deploy", cancellationToken); // shared: do not dispose
var result = await client.RunCommandAsync("uptime", cancellationToken);
return result.ExitStatus == 0 ? Results.Text(result.StandardOutputText) : Results.Problem("uptime failed.");
});
app.MapHealthChecks("/healthz");
Open a connection of your own
ISshClientFactory opens a new connection on each call, which the caller owns. Use it for long jobs that should not share, or
for something that holds a session for the application's lifetime.
/// <summary>A service that wants a connection of its own, for one job, and closes it afterwards.</summary>
public sealed class Deployer(ISshClientFactory ssh)
{
public async Task<int> RestartAsync(string service, CancellationToken cancellationToken)
{
await using var client = await ssh.ConnectAsync("deploy", cancellationToken); // new connection: ours to dispose
var result = await client.RunCommandAsync($"sudo systemctl restart {Commands.Quote(service)}", cancellationToken);
return result.ExitStatus ?? -1;
}
}
Customize the client options
ConfigureClientOptions adjusts the SshClientOptions the endpoint builds: a different host key policy, a proxy, keys held
elsewhere:
services.AddSshClient("deploy", o =>
{
o.Host = "deploy.example.com";
o.UserName = "deploy";
o.PrivateKeyFiles.Add("~/.ssh/deploy_ed25519");
// SshClientOptions is immutable: build new options, and keep what the endpoint already set.
o.ConfigureClientOptions = (provider, built) => new SshClientOptions
{
UserName = built.UserName,
Password = built.Password,
PrivateKeys = built.PrivateKeys,
KeepAliveInterval = built.KeepAliveInterval,
Logger = built.Logger,
// Changed: the host key pinned in configuration instead of known_hosts, and an outbound HTTP proxy.
HostKeyVerifier = SshHostKeyVerifier.Pinned(
provider.GetRequiredService<IConfiguration>()["Ssh:Deploy:HostKeyFingerprint"]
?? throw new InvalidOperationException("Set Ssh:Deploy:HostKeyFingerprint.")),
Proxy = SshProxy.Http("proxy.example.com", 3128),
};
});
Notes and pitfalls
- Do not dispose the shared client.
GetClientAsyncreturns a client the provider owns; the provider disposes it with the container. Dispose clients fromISshClientFactory. - No automatic retries. After a drop, the next
GetClientAsyncreconnects (with a growing delay after failures; a call during the delay fails at once with the last error). A command that was running when the connection dropped fails, and your code decides whether to run it again. - Host keys. Without
ConfigAlias, the endpoint verifies againstKnownHostsFiles(default~/.ssh/known_hosts) withStrictHostKeyChecking yes;AcceptNewHostKeysswitches toaccept-new. In a container, mount aknown_hostsfile. ConfigAliasconnects throughssh_configinstead; thenHost,Port,UserName,PrivateKeyFiles,KnownHostsFiles,AcceptNewHostKeys,KeepAliveIntervalandConfigureClientOptionsare not used.- Timeouts.
ConnectTimeout(30 s) bounds each attempt, authentication included, and fails withTimeoutException. - Health endpoints are often public. The check's description names no server software; the exception is attached for logging, so keep it out of public responses.
- Testing.
ISshClientFactoryandISshConnectionProviderare interfaces, but they returnSshClient, which only a server can create. Put your own interface in front of the operations you use; see Unit-test code that uses SshSharp.
See also
- Dependency injection (in-depth: every setting)
- A background worker
- The runnable ASP.NET Core and worker service samples