diff --git a/README.md b/README.md index 432e2b2..29390ab 100644 --- a/README.md +++ b/README.md @@ -215,8 +215,9 @@ class SshClientSettings } class SshConfigOptions { - static SshConfigOptions DefaultConfig { get; } // use [ '~/.ssh/config', '/etc/ssh/ssh_config' ] + static SshConfigOptions DefaultConfig { get; } // use DefaultConfigFilePaths. static SshConfigOptions NoConfig { get; } // use [ ] + static IReadOnlyList DefaultConfigFilePaths { get; } // [ '~/.ssh/config', '/etc/ssh/ssh_config' ] IReadOnlyList ConfigFilePaths { get; set; } diff --git a/src/Tmds.Ssh/SshClientSettings.SshConfig.cs b/src/Tmds.Ssh/SshClientSettings.SshConfig.cs index 48397c8..3ff90b2 100644 --- a/src/Tmds.Ssh/SshClientSettings.SshConfig.cs +++ b/src/Tmds.Ssh/SshClientSettings.SshConfig.cs @@ -45,7 +45,7 @@ internal static async ValueTask LoadFromConfigAsync(string de Port = sshConfig.Port ?? DefaultPort, UserKnownHostsFilePaths = sshConfig.UserKnownHostsFiles ?? DefaultUserKnownHostsFilePaths, GlobalKnownHostsFilePaths = sshConfig.GlobalKnownHostsFiles ?? DefaultGlobalKnownHostsFilePaths, - ConnectTimeout = sshConfig.ConnectTimeout > 0 ? TimeSpan.FromSeconds(sshConfig.ConnectTimeout.Value) : DefaultConnectTimeout, + ConnectTimeout = sshConfig.ConnectTimeout > 0 ? TimeSpan.FromSeconds(sshConfig.ConnectTimeout.Value) : options.ConnectTimeout, KeyExchangeAlgorithms = kexAlgorithms, ServerHostKeyAlgorithms = hostKeyAlgorithms, PublicKeyAcceptedAlgorithms = publicKeyAcceptedAlgorithms, diff --git a/src/Tmds.Ssh/SshConfigOptions.cs b/src/Tmds.Ssh/SshConfigOptions.cs index 942a4c2..8e30f51 100644 --- a/src/Tmds.Ssh/SshConfigOptions.cs +++ b/src/Tmds.Ssh/SshConfigOptions.cs @@ -13,9 +13,10 @@ namespace Tmds.Ssh; public sealed class SshConfigOptions { - public static readonly SshConfigOptions DefaultConfig = CreateDefault(); + public static IReadOnlyList DefaultConfigFilePaths { get; } = CreateDefaultConfigFilePaths(); + public static SshConfigOptions DefaultConfig { get; }= CreateDefault(); - public static readonly SshConfigOptions NoConfig = CreateNoConfig(); + public static SshConfigOptions NoConfig { get; }= CreateNoConfig(); private bool _locked; @@ -136,10 +137,26 @@ private static SshConfigOptions CreateDefault() private static SshConfigOptions CreateNoConfig() { - var config = new SshConfigOptions([]); + var config = new SshConfigOptions(DefaultConfigFilePaths); config.Lock(); return config; } + + private static IReadOnlyList CreateDefaultConfigFilePaths() + { + string userConfigFilePath = Path.Combine(SshClientSettings.Home, ".ssh", "config"); + string systemConfigFilePath; + if (Platform.IsWindows) + { + systemConfigFilePath = Path.Combine(Environment.GetFolderPath(SpecialFolder.CommonApplicationData, SpecialFolderOption.DoNotVerify), "ssh", "ssh_config"); + } + else + { + systemConfigFilePath = "/etc/ssh/ssh_config"; + } + + return [userConfigFilePath, systemConfigFilePath]; + } } \ No newline at end of file diff --git a/test/Tmds.Ssh.Tests/ConnectTests.cs b/test/Tmds.Ssh.Tests/ConnectTests.cs index 442511d..62a9606 100644 --- a/test/Tmds.Ssh.Tests/ConnectTests.cs +++ b/test/Tmds.Ssh.Tests/ConnectTests.cs @@ -294,4 +294,183 @@ public async Task VerificationExceptionWrapped() Assert.Equal(exceptionThrown, ex.InnerException); } + + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task AutoConnect(bool autoConnect) + { + using var client = await _sshServer.CreateClientAsync( + configure: settings => settings.AutoConnect = autoConnect, + connect: false + ); + + if (autoConnect) + { + using var sftpClient = await client.OpenSftpClientAsync(); + } + else + { + await Assert.ThrowsAsync(() => client.OpenSftpClientAsync()); + } + } + + [Fact] + public async Task AutoConnectAllowsExplicitConnectBeforeImplicitConnect() + { + using var client = await _sshServer.CreateClientAsync( + configure: settings => settings.AutoConnect = true, + connect: false + ); + + await client.ConnectAsync(); + + using var sftpClient = await client.OpenSftpClientAsync(); + } + + [Fact] + public async Task AutoConnectDisallowsExplicitConnectAfterImplicitConnect() + { + // If a user calls ConnectAsync, we require it to happen before performing operations. + // If there is an issue connecting, this ConnectAsync will throw the connect exception. + // And, its cancellation token enables cancelling the connect. + using var client = await _sshServer.CreateClientAsync( + configure: settings => settings.AutoConnect = true, + connect: false + ); + + var pending = client.OpenSftpClientAsync(); + + await Assert.ThrowsAsync(() => client.ConnectAsync()); + } + + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task AutoReconnect(bool autoReconnect) + { + using var client = await _sshServer.CreateClientAsync( + configure: settings => settings.AutoReconnect = autoReconnect + ); + + using var sftpClient = await client.OpenSftpClientAsync(); + + client.ForceConnectionClose(); + + if (autoReconnect) + { + using var sftpClient2 = await client.OpenSftpClientAsync(); + } + else + { + await Assert.ThrowsAsync(() => client.OpenSftpClientAsync()); + } + } + + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task SshConfig_AutoConnect(bool autoConnect) + { + using var client = await _sshServer.CreateClientAsync( + new SshConfigOptions([_sshServer.SshConfigFilePath]) + { + AutoConnect = autoConnect + }, + connect: false + ); + + if (autoConnect) + { + using var sftpClient = await client.OpenSftpClientAsync(); + } + else + { + await Assert.ThrowsAsync(() => client.OpenSftpClientAsync()); + } + } + + [InlineData(true)] + [InlineData(false)] + [Theory] + public async Task SshConfig_AutoReconnect(bool autoReconnect) + { + using var client = await _sshServer.CreateClientAsync( + new SshConfigOptions([_sshServer.SshConfigFilePath]) + { + AutoReconnect = autoReconnect + } + ); + + using var sftpClient = await client.OpenSftpClientAsync(); + + client.ForceConnectionClose(); + + if (autoReconnect) + { + using var sftpClient2 = await client.OpenSftpClientAsync(); + } + else + { + await Assert.ThrowsAsync(() => client.OpenSftpClientAsync()); + } + } + + [Theory] + [InlineData(1)] + [InlineData(1000)] + public async Task SshConfig_Timeout(int msTimeout) + { + IPAddress address = IPAddress.Loopback; + using var s = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + s.Bind(new IPEndPoint(address, 0)); + s.Listen(); + int port = (s.LocalEndPoint as IPEndPoint)!.Port; + + using var client = new SshClient($"user@{address}:{port}", + new SshConfigOptions([_sshServer.SshConfigFilePath]) + { + ConnectTimeout = TimeSpan.FromMilliseconds(msTimeout) + }); + + SshConnectionException exception = await Assert.ThrowsAnyAsync(() => client.ConnectAsync()); + Assert.IsType(exception.InnerException); + } + + [Fact] + public async Task SshConfig_ConnectFailure() + { + await Assert.ThrowsAnyAsync(() => + _sshServer.CreateClientAsync(SshConfigOptions.NoConfig)); + } + + [Fact] + public async Task SshConfig_HostAuthentication() + { + using TempFile tempFile = new TempFile(Path.GetTempFileName()); + File.WriteAllText(tempFile.Path, + $""" + IdentityFile "{_sshServer.TestUserIdentityFile}" + """); + using var _ = await _sshServer.CreateClientAsync( + new SshConfigOptions([tempFile.Path]) + { + HostAuthentication = + (KnownHostResult knownHostResult, SshConnectionInfo connectionInfo, CancellationToken cancellationToken) => + { + Assert.Equal(KnownHostResult.Unknown, knownHostResult); + Assert.Equal(_sshServer.ServerHost, connectionInfo.HostName); + Assert.Equal(_sshServer.ServerPort, connectionInfo.Port); + string[] serverKeyFingerPrints = + [ + _sshServer.RsaKeySHA256FingerPrint, + _sshServer.Ed25519KeySHA256FingerPrint, + _sshServer.EcdsaKeySHA256FingerPrint + ]; + Assert.Contains(serverKeyFingerPrints, key => key == connectionInfo.ServerKey.SHA256FingerPrint); + return ValueTask.FromResult(true); + } + } + ); + } } diff --git a/test/Tmds.Ssh.Tests/SshClientTests.cs b/test/Tmds.Ssh.Tests/SshClientTests.cs deleted file mode 100644 index a2ee064..0000000 --- a/test/Tmds.Ssh.Tests/SshClientTests.cs +++ /dev/null @@ -1,88 +0,0 @@ -using System; -using System.Threading.Tasks; -using Xunit; - -namespace Tmds.Ssh.Tests; - -[Collection(nameof(SshServerCollection))] -public class SshClientTests -{ - private readonly SshServer _sshServer; - - public SshClientTests(SshServer sshServer) - { - _sshServer = sshServer; - } - - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task AutoConnect(bool autoConnect) - { - using var client = await _sshServer.CreateClientAsync( - configure: settings => settings.AutoConnect = autoConnect, - connect: false - ); - - if (autoConnect) - { - using var sftpClient = await client.OpenSftpClientAsync(); - } - else - { - await Assert.ThrowsAsync(() => client.OpenSftpClientAsync()); - } - } - - [Fact] - public async Task AutoConnectAllowsExplicitConnectBeforeImplicitConnect() - { - using var client = await _sshServer.CreateClientAsync( - configure: settings => settings.AutoConnect = true, - connect: false - ); - - await client.ConnectAsync(); - - using var sftpClient = await client.OpenSftpClientAsync(); - } - - [Fact] - public async Task AutoConnectDisallowsExplicitConnectAfterImplicitConnect() - { - // If a user calls ConnectAsync, we require it to happen before performing operations. - // If there is an issue connecting, this ConnectAsync will throw the connect exception. - // And, its cancellation token enables cancelling the connect. - using var client = await _sshServer.CreateClientAsync( - configure: settings => settings.AutoConnect = true, - connect: false - ); - - var pending = client.OpenSftpClientAsync(); - - await Assert.ThrowsAsync(() => client.ConnectAsync()); - } - - [InlineData(true)] - [InlineData(false)] - [Theory] - public async Task AutoReconnect(bool autoReconnect) - { - using var client = await _sshServer.CreateClientAsync( - configure: settings => settings.AutoReconnect = autoReconnect - ); - - using var sftpClient = await client.OpenSftpClientAsync(); - - client.ForceConnectionClose(); - - if (autoReconnect) - { - using var sftpClient2 = await client.OpenSftpClientAsync(); - } - else - { - await Assert.ThrowsAsync(() => client.OpenSftpClientAsync()); - } - } -} diff --git a/test/Tmds.Ssh.Tests/SshConfigTests.cs b/test/Tmds.Ssh.Tests/SshConfigTests.cs index d7ca101..1ce47a8 100644 --- a/test/Tmds.Ssh.Tests/SshConfigTests.cs +++ b/test/Tmds.Ssh.Tests/SshConfigTests.cs @@ -384,24 +384,4 @@ private static async Task DetermineConfigAsync(string config, string? File.WriteAllText(tempFile.Path, config); return await SshConfig.DetermineConfigForHost(username, host, port, [tempFile.Path], cancellationToken: default); } - - struct TempFile : IDisposable - { - public string Path { get; } - - public TempFile(string path) - { - Path = path; - } - - public void Dispose() - { - try - { - File.Delete(Path); - } - catch - { } - } - } } diff --git a/test/Tmds.Ssh.Tests/SshServer.cs b/test/Tmds.Ssh.Tests/SshServer.cs index da7db78..3461ff6 100644 --- a/test/Tmds.Ssh.Tests/SshServer.cs +++ b/test/Tmds.Ssh.Tests/SshServer.cs @@ -28,6 +28,7 @@ public class SshServer : IDisposable public int ServerPort => _port; public string KnownHostsFilePath => _knownHostsFile; public string KerberosConfigFilePath => _krbConfigFilePath; + public string SshConfigFilePath => _sshConfigFilePath; public string Destination => $"{TestUser}@{ServerHost}:{ServerPort}"; public string RsaKeySHA256FingerPrint => "sqggBLsad/k11YcLVgwFnq6Bs7WRYgD1u+WhBmVKMVM"; @@ -39,6 +40,7 @@ public class SshServer : IDisposable private readonly int _port; private readonly string _knownHostsFile; private readonly string _krbConfigFilePath; + private readonly string _sshConfigFilePath; private bool _useDockerInstead; public SshServer() @@ -79,6 +81,7 @@ public SshServer() _knownHostsFile = WriteKnownHostsFile(_host, _port); _krbConfigFilePath = WriteKerberosConfigFile(kdcPort); + _sshConfigFilePath = WriteSshConfigFile(_knownHostsFile, TestUserIdentityFile); if (!OperatingSystem.IsWindows()) { @@ -111,6 +114,18 @@ string WriteKnownHostsFile(string host, int port) return filename; } + string WriteSshConfigFile(string knownHostsFilePath, string identityFilePath) + { + string contents = + $""" + UserKnownHostsFile "{knownHostsFilePath}" + IdentityFile "{identityFilePath}" + """; + string filename = Path.GetTempFileName(); + File.WriteAllText(filename, contents); + return filename; + } + string WriteKerberosConfigFile(int kdcPort) { string configTemplate = File.ReadAllText(Path.Combine(ContainerBuildContext, "krb5.conf")); @@ -182,6 +197,10 @@ public void Dispose() { File.Delete(_krbConfigFilePath); } + if (_sshConfigFilePath != null) + { + File.Delete(_sshConfigFilePath); + } if (_containerId != null) { Run("podman", "rm", "-f", _containerId); @@ -250,6 +269,18 @@ private void VerifyServerWorks() Assert.Contains(HelloWorld, output); } + public async Task CreateClientAsync(SshConfigOptions configOptions, CancellationToken cancellationToken = default, bool connect = true) + { + var client = new SshClient(Destination, configOptions); + + if (connect) + { + await client.ConnectAsync(cancellationToken); + } + + return client; + } + public async Task CreateClientAsync(Action? configure = null, CancellationToken cancellationToken = default, bool connect = true) { var settings = CreateSshClientSettings(configure); diff --git a/test/Tmds.Ssh.Tests/TempFile.cs b/test/Tmds.Ssh.Tests/TempFile.cs new file mode 100644 index 0000000..5db2f96 --- /dev/null +++ b/test/Tmds.Ssh.Tests/TempFile.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; + +namespace Tmds.Ssh.Tests; + +struct TempFile : IDisposable +{ + public string Path { get; } + + public TempFile(string path) + { + Path = path; + } + + public void Dispose() + { + try + { + File.Delete(Path); + } + catch + { } + } +}