Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support configuring from ssh_config. #200

Merged
merged 20 commits into from
Aug 3, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ The `Tmds.Ssh` library provides a managed .NET SSH client implementation.

It has an async [API](#api) and leverages the modern .NET primitives, like `Span`, to minimize allocations.

The library automatically picks up OpenSSH config files, like private keys, and known hosts.
The library supports OpenSSH file formats for private keys, known hosts and OpenSSH config.

The library targets modern .NET (Core). It does not support .NET Framework due to missing BCL APIs to implement the SSH key exchange.

Expand All @@ -23,7 +23,7 @@ Update `Program.cs`:
```cs
using Tmds.Ssh;

using var sshClient = new SshClient("localhost", SshConfigOptions.Default);
using var sshClient = new SshClient("localhost", SshConfigOptions.DefaultConfig);
using var process = await sshClient.ExecuteAsync("echo 'hello world!'");
(bool isError, string? line) = await process.ReadLineAsync();
Console.WriteLine(line);
Expand Down Expand Up @@ -189,8 +189,8 @@ class SftpFile : Stream
class SshClientSettings
{
static IReadOnlyList<Credential> DefaultCredentials { get; } // = [ PrivateKeyCredential("~/.ssh/id_rsa"), KerberosCredential() ]
static IReadOnlyList<string> DefaultUserKnownHostsFilePaths { get; } // = [ '~/.ssh/known_hosts' ].
static IReadOnlyList<string> DefaultGlobalKnownHostsFilePaths { get; } // = [ '/etc/ssh/known_hosts' ].
static IReadOnlyList<string> DefaultUserKnownHostsFilePaths { get; } // = [ '~/.ssh/known_hosts' ]
static IReadOnlyList<string> DefaultGlobalKnownHostsFilePaths { get; } // = [ '/etc/ssh/known_hosts' ]

SshClientSettings();
SshClientSettings(string destination);
Expand All @@ -208,21 +208,23 @@ class SshClientSettings

IReadOnlyList<string> GlobalKnownHostsFilePaths { get; set; } = DefaultGlobalKnownHostsFilePaths;
IReadOnlyList<string> UserKnownHostsFilePaths { get; set; } = DefaultUserKnownHostsFilePaths;
HostAuthentication? HostAuthentication { get; set; }
HostAuthentication? HostAuthentication { get; set; } // not called when known to be trusted/revoked.
bool UpdateKnownHostsFileAfterAuthentication { get; set; } = false;
int MinimumRSAKeySize { get; set; } = 2048;
}
class SshConfigOptions
{
static SshConfigOptions Default { get; } // use [ '~/.ssh/config', '/etc/ssh/ssh_config' ]
static SshConfigOptions NoConfig { get; } // use [ ]
static SshConfigOptions DefaultConfig { get; } // use [ '~/.ssh/config', '/etc/ssh/ssh_config' ]
static SshConfigOptions NoConfig { get; } // use [ ]

IReadOnlyList<string> ConfigFilePaths { get; set; }

TimeSpan ConnectTimeout { get; set; } // = 15s, overridden by config timeout (if set)

bool AutoConnect { get; set; } = true;
bool AutoReconnect { get; set; }
// Will be overridden by config file connect timeout (if set).
TimeSpan ConnectTimeout { get; set; } // = 15s

HostAuthentication? HostAuthentication { get; set; } // Called for Unknown when StrictHostKeyChecking is 'ask' (default)
}
class SftpClientOptions
{ }
Expand Down
2 changes: 1 addition & 1 deletion examples/scp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@

string sshDestination = source.SshDestination ?? destination.SshDestination!;

using SshClient client = new SshClient(sshDestination, SshConfigOptions.Default);
using SshClient client = new SshClient(sshDestination, SshConfigOptions.DefaultConfig);

await client.ConnectAsync();

Expand Down
2 changes: 1 addition & 1 deletion examples/ssh/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ static async Task Main(string[] args)
string destination = args.Length >= 1 ? args[0] : "localhost";
string command = args.Length >= 2 ? args[1] : "echo 'hello world'";

using SshClient client = new SshClient(destination, SshConfigOptions.Default);
using SshClient client = new SshClient(destination, SshConfigOptions.DefaultConfig);
await client.ConnectAsync();

using var process = await client.ExecuteAsync(command);
Expand Down
2 changes: 1 addition & 1 deletion src/Tmds.Ssh/KnownHostResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ namespace Tmds.Ssh;

public enum KnownHostResult
{
Trusted, // Server is known and has not changed.
Trusted, // Server is known and has not changed.

Revoked, // Key is revoked.

Expand Down
4 changes: 2 additions & 2 deletions src/Tmds.Ssh/Platform.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
// This file is part of Tmds.Ssh which is released under MIT.
// See file LICENSE for full license details.

using System.Runtime.InteropServices;
using System;

namespace Tmds.Ssh;

static class Platform
{
public static bool IsWindows => RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
public static bool IsWindows => OperatingSystem.IsWindows();
}
2 changes: 1 addition & 1 deletion src/Tmds.Ssh/SshClientSettings.Defaults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ private static IReadOnlyList<Credential> CreateDefaultCredentials()
private static IReadOnlyList<string> CreateDefaultGlobalKnownHostsFilePaths()
{
string path;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (Platform.IsWindows)
{
path = Path.Combine(Environment.GetFolderPath(SpecialFolder.CommonApplicationData, SpecialFolderOption.DoNotVerify), "ssh", "known_hosts");
}
Expand Down
47 changes: 47 additions & 0 deletions src/Tmds.Ssh/SshClientSettings.SshConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,53 @@ internal static async ValueTask<SshClientSettings> LoadFromConfigAsync(string de
Credentials = DetermineCredentials(sshConfig)
};

SshConfig.StrictHostKeyChecking hostKeyChecking = sshConfig.HostKeyChecking ?? SshConfig.StrictHostKeyChecking.Ask;
switch (hostKeyChecking)
{
case SshConfig.StrictHostKeyChecking.No:
settings.UpdateKnownHostsFileAfterAuthentication = true;
// Allow unknown and changed.
settings.HostAuthentication =
(KnownHostResult knownHostResult, SshConnectionInfo connectionInfo, CancellationToken cancellationToken)
=> ValueTask.FromResult(knownHostResult is KnownHostResult.Unknown or KnownHostResult.Changed);
break;

case SshConfig.StrictHostKeyChecking.AcceptNew:
settings.UpdateKnownHostsFileAfterAuthentication = true;
// Disallow changed. Allow unknown.
settings.HostAuthentication =
(KnownHostResult knownHostResult, SshConnectionInfo connectionInfo, CancellationToken cancellationToken)
=> ValueTask.FromResult(knownHostResult == KnownHostResult.Unknown);
break;

case SshConfig.StrictHostKeyChecking.Ask:
settings.UpdateKnownHostsFileAfterAuthentication = true;
// Disallow changed, and ask for unknown keys.
if (options.HostAuthentication is HostAuthentication authentication)
{
settings.HostAuthentication =
(KnownHostResult knownHostResult, SshConnectionInfo connectionInfo, CancellationToken cancellationToken) =>
{
if (knownHostResult == KnownHostResult.Changed)
{
return ValueTask.FromResult(false);
}
return authentication(knownHostResult, connectionInfo, cancellationToken);
};
}
else
{
settings.HostAuthentication = delegate { return ValueTask.FromResult(false); };
}
break;

case SshConfig.StrictHostKeyChecking.Yes:
default:
settings.UpdateKnownHostsFileAfterAuthentication = false;
settings.HostAuthentication = delegate { return ValueTask.FromResult(false); };
break;
}

return settings;
}

Expand Down
31 changes: 30 additions & 1 deletion src/Tmds.Ssh/SshConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ sealed class SshConfig
private static readonly string Home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify);
private static readonly List<string> ListOfNone = [];

public enum StrictHostKeyChecking
{
Yes,
AcceptNew,
No,
Ask
}

public enum AlgorithmListOperation
{
Prepend,
Expand Down Expand Up @@ -55,6 +63,7 @@ public struct AlgorithmList
public bool? GssApiDelegateCredentials { get; set; }
public string? GssApiServerIdentity { get; set; }
public List<string>? IdentityFiles { get; set; }
public StrictHostKeyChecking? HostKeyChecking { get; set; }

private static readonly HashSet<string> KnownUnsupportedOptions =
new HashSet<string>(
Expand Down Expand Up @@ -113,7 +122,6 @@ public struct AlgorithmList
"gssapikeyexchange", // gssapikeyexchange is not supported
"gssapikexalgorithms", // gssapikeyexchange is not supported
"nohostauthenticationforlocalhost", // not supported
"stricthostkeychecking", // unsupported. We behave like the strictest setting ('yes')
"updatehostkeys", // unsupported. This is for updating the known hosts file with keys the server sends us
"ignoreunknown", // unsupported.

Expand Down Expand Up @@ -303,6 +311,27 @@ private static void ConfigureFromConfigFile(SshConfig config, string host, strin
ThrowUnsupportedKeyword(keyword, remainder);
break;

case "stricthostkeychecking":
if (!config.HostKeyChecking.HasValue)
{
ReadOnlySpan<char> value = GetKeywordValue(keyword, ref remainder);

if (value.Equals("no", StringComparison.OrdinalIgnoreCase) ||
value.Equals("off", StringComparison.OrdinalIgnoreCase))
{
config.HostKeyChecking = StrictHostKeyChecking.No;
}
else if (value.Equals("accept-new", StringComparison.OrdinalIgnoreCase))
{
config.HostKeyChecking = StrictHostKeyChecking.AcceptNew;
}
else
tmds marked this conversation as resolved.
Show resolved Hide resolved
{
config.HostKeyChecking = StrictHostKeyChecking.Yes;
}
}
break;

// we don't support running local/remote commands.
case "permitlocalcommand":
ThrowUnsupportedWhenKeywordValueIsNot(keyword, ref remainder, "no");
Expand Down
20 changes: 15 additions & 5 deletions src/Tmds.Ssh/SshConfigOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,17 @@ namespace Tmds.Ssh;

public sealed class SshConfigOptions
{
public static readonly SshConfigOptions Default = CreateDefault();
public static readonly SshConfigOptions DefaultConfig = CreateDefault();

public static readonly SshConfigOptions NoConfig = CreateNoConfig();

private bool _locked;

private IReadOnlyList<string> _configFilePaths;

private bool _autoConnect = true;

private bool _autoReconnect = false;

private TimeSpan _connectTimeout = SshClientSettings.DefaultConnectTimeout;
private HostAuthentication? _hostAuthentication;

public SshConfigOptions(IReadOnlyList<string> configFilePaths)
{
Expand Down Expand Up @@ -77,6 +75,18 @@ public TimeSpan ConnectTimeout
}
}

// Called when StrictHostKeyChecking is Ask and the key is unknown.
public HostAuthentication? HostAuthentication
{
get => _hostAuthentication;
set
{
ThrowIfLocked();

_hostAuthentication = value;
}
}

private IReadOnlyList<string> ValidateConfigFilePaths(IReadOnlyList<string> argument, [CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
ArgumentNullException.ThrowIfNull(argument, paramName);
Expand Down Expand Up @@ -109,7 +119,7 @@ private static SshConfigOptions CreateDefault()
{
string userConfigFilePath = Path.Combine(SshClientSettings.Home, ".ssh", "config");
string systemConfigFilePath;
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (Platform.IsWindows)
{
systemConfigFilePath = Path.Combine(Environment.GetFolderPath(SpecialFolder.CommonApplicationData, SpecialFolderOption.DoNotVerify), "ssh", "ssh_config");
}
Expand Down
25 changes: 24 additions & 1 deletion test/Tmds.Ssh.Tests/SshClientSettingsTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.IO;
using Xunit;
using static System.Environment;

namespace Tmds.Ssh.Tests;

Expand All @@ -11,11 +12,28 @@ public void Defaults()
{
var settings = new SshClientSettings();
Assert.Equal(TimeSpan.FromSeconds(15), settings.ConnectTimeout);
Assert.Equal(22, settings.Port);
Assert.Equal(2048, settings.MinimumRSAKeySize);
Assert.Equal(string.Empty, settings.UserName);
Assert.Equal(string.Empty, settings.HostName);
Assert.Equal(22, settings.Port);
Assert.Equal(SshClientSettings.DefaultCredentials, settings.Credentials);
Assert.False(settings.UpdateKnownHostsFileAfterAuthentication);
Assert.True(settings.AutoConnect);
Assert.False(settings.AutoReconnect);
Assert.Equal(new[] { DefaultKnownHostsFile }, settings.UserKnownHostsFilePaths);
Assert.Equal(new[] { DefaultGlobalKnownHostsFile }, settings.GlobalKnownHostsFilePaths);
Assert.Null(settings.HostAuthentication);
Assert.Equal(new[] { new Name("ecdh-sha2-nistp256"), new Name("ecdh-sha2-nistp384"), new Name("ecdh-sha2-nistp521") }, settings.KeyExchangeAlgorithms);
Assert.Equal(new[] { new Name("ecdsa-sha2-nistp521"), new Name("ecdsa-sha2-nistp384"), new Name("ecdsa-sha2-nistp256"), new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.ServerHostKeyAlgorithms);
Assert.Equal(new[] { new Name("rsa-sha2-512"), new Name("rsa-sha2-256") }, settings.PublicKeyAcceptedAlgorithms);
Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsClientToServer);
Assert.Equal(new[] { new Name("aes256-gcm@openssh.com"), new Name("aes128-gcm@openssh.com") }, settings.EncryptionAlgorithmsServerToClient);
Assert.Equal(Array.Empty<Name>(), settings.MacAlgorithmsClientToServer);
Assert.Equal(Array.Empty<Name>(), settings.MacAlgorithmsServerToClient);
Assert.Equal(new[] { new Name("none") }, settings.CompressionAlgorithmsClientToServer);
Assert.Equal(new[] { new Name("none") }, settings.CompressionAlgorithmsServerToClient);
Assert.Equal(Array.Empty<Name>(), settings.LanguagesClientToServer);
Assert.Equal(Array.Empty<Name>(), settings.LanguagesServerToClient);
}

[Theory]
Expand All @@ -41,4 +59,9 @@ private static string DefaultKnownHostsFile
=> Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.DoNotVerify),
".ssh",
"known_hosts");

private static string DefaultGlobalKnownHostsFile
=> OperatingSystem.IsWindows()
? Path.Combine(Environment.GetFolderPath(SpecialFolder.CommonApplicationData, SpecialFolderOption.DoNotVerify), "ssh", "known_hosts")
: "/etc/ssh/known_hosts";
}
Loading