Skip to content

Commit

Permalink
Support configuring the client in code using ssh_config options. (#227)
Browse files Browse the repository at this point in the history
  • Loading branch information
tmds authored Sep 20, 2024
1 parent 1d63109 commit f200264
Show file tree
Hide file tree
Showing 13 changed files with 574 additions and 328 deletions.
5 changes: 3 additions & 2 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@
<ItemGroup>
<!-- Tmds.Ssh dependencies -->
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.4.0" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
<!-- Test dependencies -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageVersion Include="xunit" Version="2.9.0" />
Expand All @@ -13,6 +12,8 @@
<PackageVersion Include="System.Linq.Async" Version="6.0.1" />
<PackageVersion Include="Tmds.ExecFunction" Version="0.7.1" />
<!-- Example dependencies -->
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="8.0.0" />
<PackageVersion Include="System.CommandLine" Version="2.0.0-beta4.22272.1" />
<PackageVersion Include="Azure.Identity" Version="1.12.0" />
<PackageVersion Include="Azure.Security.KeyVault.Keys" Version="4.6.0" />
</ItemGroup>
Expand Down
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ class SshConfigOptions
static IReadOnlyList<string> DefaultConfigFilePaths { get; } // [ '~/.ssh/config', '/etc/ssh/ssh_config' ]
IReadOnlyList<string> ConfigFilePaths { get; set; }
IReadOnlyDictionary<SshConfigOption, SshConfigOptionValue> Options { get; set; }

TimeSpan ConnectTimeout { get; set; } // = 15s, overridden by config timeout (if set)
Expand All @@ -245,6 +246,41 @@ class SshConfigOptions

HostAuthentication? HostAuthentication { get; set; } // Called for Unknown when StrictHostKeyChecking is 'ask' (default)
}
public enum SshConfigOption
{
Hostname,
User,
Port,
ConnectTimeout,
GlobalKnownHostsFile,
UserKnownHostsFile,
HashKnownHosts,
StrictHostKeyChecking,
PreferredAuthentications,
PubkeyAuthentication,
IdentityFile,
GSSAPIAuthentication,
GSSAPIDelegateCredentials,
GSSAPIServerIdentity,
RequiredRSASize,
SendEnv,
Ciphers,
HostKeyAlgorithms,
KexAlgorithms,
MACs,
PubkeyAcceptedAlgorithms
}
struct SshConfigOptionValue
{
SshConfigOptionValue(string value);
SshConfigOptionValue(IEnumerable<string> values);
static implicit operator SshConfigOptionValue(string value);

bool IsEmpty { get; }
bool IsSingleValue { get; }
string? FirstValue { get; }
IEnumerable<string> Values { get; }
}
class SftpClientOptions
{ }
class SftpException : IOException
Expand Down
55 changes: 51 additions & 4 deletions examples/ssh/Program.cs
Original file line number Diff line number Diff line change
@@ -1,12 +1,34 @@
using System;
using System.Collections.Generic;
using System.CommandLine;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;

namespace Tmds.Ssh;

class Program
{
static async Task Main(string[] args)
static Task<int> Main(string[] args)
{
var destinationArg = new Argument<string>(name: "destination", () => "localhost")
{ Arity = ArgumentArity.ZeroOrOne };
var commandArg = new Argument<string>(name: "command", () => "echo 'hello world'")
{ Arity = ArgumentArity.ZeroOrOne };
var sshConfigOptions = new Option<string[]>(new[] { "-o", "--option" },
description: $"Set an SSH Config option, for example: [email protected].{Environment.NewLine}Supported options: {string.Join(", ", Enum.GetValues<SshConfigOption>().Select(o => o.ToString()))}.")
{ Arity = ArgumentArity.ZeroOrMore };

var rootCommand = new RootCommand("Execute a command on a remote system over SSH.");
rootCommand.AddOption(sshConfigOptions);
rootCommand.AddArgument(destinationArg);
rootCommand.AddArgument(commandArg);
rootCommand.SetHandler(ExecuteAsync, destinationArg, commandArg, sshConfigOptions);

return rootCommand.InvokeAsync(args);
}

static async Task ExecuteAsync(string destination, string command, string[] options)
{
bool trace = IsEnvvarTrue("TRACE");
bool log = trace || IsEnvvarTrue("LOG");
Expand All @@ -21,10 +43,9 @@ static async Task Main(string[] args)
}
});

string destination = args.Length >= 1 ? args[0] : "localhost";
string command = args.Length >= 2 ? args[1] : "echo 'hello world'";
SshConfigOptions configOptions = CreateSshConfigOptions(options);

using SshClient client = new SshClient(destination, loggerFactory);
using SshClient client = new SshClient(destination, configOptions, loggerFactory);

using var process = await client.ExecuteAsync(command);
Task[] tasks = new[]
Expand Down Expand Up @@ -73,6 +94,32 @@ static void PrintExceptions(Task[] tasks)
}
}

private static SshConfigOptions CreateSshConfigOptions(string[] options)
{
SshConfigOptions configOptions = new SshConfigOptions(SshConfigOptions.DefaultConfigFilePaths);

Dictionary<SshConfigOption, SshConfigOptionValue> optionsDict = new();
foreach (var option in options)
{
string[] split = option.Split('=', 2);
if (split.Length != 2)
{
throw new ArgumentException($"Option '{option}' is not in the <Key>=<Value> format.");
}
if (Enum.TryParse<SshConfigOption>(split[0], ignoreCase: true, out var key))
{
optionsDict[key] = split[1];
}
else
{
throw new ArgumentException($"Unsupported option: {option}.");
}
}
configOptions.Options = optionsDict;

return configOptions;
}

static bool IsEnvvarTrue(string variableName)
{
string? value = Environment.GetEnvironmentVariable(variableName);
Expand Down
1 change: 1 addition & 0 deletions examples/ssh/ssh.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
<PackageReference Include="System.CommandLine" />
</ItemGroup>

</Project>
8 changes: 1 addition & 7 deletions src/Tmds.Ssh/SshClientSettings.SshConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,9 @@ partial class SshClientSettings
AlgorithmNames.Password
];

internal static ValueTask<SshClientSettings> LoadFromConfigAsync(string destination, SshConfigOptions options, CancellationToken cancellationToken = default)
{
(string? userName, string host, int? port) = ParseDestination(destination);
return LoadFromConfigAsync(userName, host, port, options, cancellationToken);
}

internal static async ValueTask<SshClientSettings> LoadFromConfigAsync(string? userName, string host, int? port, SshConfigOptions options, CancellationToken cancellationToken = default)
{
SshConfig sshConfig = await SshConfig.DetermineConfigForHost(userName, host, port, options.ConfigFilePaths, cancellationToken);
SshConfig sshConfig = await SshConfig.DetermineConfigForHost(userName, host, port, options.Options, options.ConfigFilePaths, cancellationToken);

List<Name> ciphers = DetermineAlgorithms(sshConfig.Ciphers, DefaultEncryptionAlgorithms, SupportedEncryptionAlgorithms);
List<Name> hostKeyAlgorithms = DetermineAlgorithms(sshConfig.HostKeyAlgorithms, DefaultServerHostKeyAlgorithms, SupportedServerHostKeyAlgorithms);
Expand Down
Loading

0 comments on commit f200264

Please sign in to comment.