From 885fb17bdc491704c104aecaddecab541e1e0b74 Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Tue, 28 Jul 2026 16:52:09 +0200 Subject: [PATCH] Clear the SSH gate: window-change reaches the remote, and licence as MIT Licence is MIT, set solution-wide rather than only on the packable project: DodoSSH.Contracts is published so clients can build against it, and a package with no licence expression is one a corporate policy scanner rejects outright. The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0 exposes ShellStream.ChangeWindowSize, but a method existing is not the remote observing it, so the tests read `stty size` back from a real sshd after resizing rather than asserting the call did not throw. Repeated resizes each take effect too, which matters because dragging a window edge produces a stream of them. The IChannelSession fallback is not needed. Also verified against a real sshd: password and public-key auth, that the host key arrives as a raw blob we can fingerprint ourselves rather than reading SSH.NET's MD5 property, and that refusing the key via CanTrust actually aborts the connection -- without which the TOFU dialog would be decoration. Kept as a permanent suite, not deleted after the spike. An upgrade that silently stopped sending the request would present as wrapped output only after a resize, which is easy to misattribute to the terminal emulator. Two bugs in the test itself, both worth naming because either would have been read as "resize does not work": - A PTY emits CRLF, and the anchored regex rejected the CR. The output visibly contained `24 80` while the match failed. - Each read can begin with output still buffered from the previous command, including its size line. Taking the first match would have reported the pre-resize size. platform-flags.md now records window-change as resolved rather than unverified -- a stale flag is worse than none -- plus the three real SSH.NET limits found on the way: ShellStream does not override ReadAsync so every idle session parks a pool thread, one connection cannot serve both SshClient and SftpClient, and agent forwarding needs an upstream change. --- Directory.Build.props | 7 + Directory.Packages.props | 13 + DodoSSH.slnx | 2 + LICENSE | 21 ++ README.md | 2 +- docs/platform-flags.md | 24 +- .../DodoSSH.Client.Ssh.csproj | 16 + .../SshHostKeyFingerprint.cs | 68 ++++ src/DodoSSH.Client.Ssh/packages.lock.json | 48 +++ tests/DodoSSH.Api.Tests/packages.lock.json | 44 +-- .../DodoSSH.Client.Ssh.Tests.csproj | 19 ++ .../PtyAndResizeSpikeTests.cs | 249 ++++++++++++++ .../SshServerFixture.cs | 132 ++++++++ .../packages.lock.json | 313 ++++++++++++++++++ .../packages.lock.json | 44 +-- 15 files changed, 956 insertions(+), 46 deletions(-) create mode 100644 LICENSE create mode 100644 src/DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj create mode 100644 src/DodoSSH.Client.Ssh/SshHostKeyFingerprint.cs create mode 100644 src/DodoSSH.Client.Ssh/packages.lock.json create mode 100644 tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj create mode 100644 tests/DodoSSH.Client.Ssh.Tests/PtyAndResizeSpikeTests.cs create mode 100644 tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs create mode 100644 tests/DodoSSH.Client.Ssh.Tests/packages.lock.json diff --git a/Directory.Build.props b/Directory.Build.props index 9e6faf9..9300cc3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,6 +37,13 @@ DodoTech DodoSSH en + Copyright (c) 2026 Jaap-Jan de Wit (DodoTech) + + MIT diff --git a/Directory.Packages.props b/Directory.Packages.props index 4977bbd..680b22a 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -64,6 +64,17 @@ + + + + + @@ -86,6 +97,8 @@ + + + + + + + + + + + + diff --git a/src/DodoSSH.Client.Ssh/SshHostKeyFingerprint.cs b/src/DodoSSH.Client.Ssh/SshHostKeyFingerprint.cs new file mode 100644 index 0000000..15a3bab --- /dev/null +++ b/src/DodoSSH.Client.Ssh/SshHostKeyFingerprint.cs @@ -0,0 +1,68 @@ +using System.Security.Cryptography; + +namespace DodoSSH.Client.Ssh; + +/// +/// OpenSSH-style SSH host key fingerprints. See docs/crypto.md §8. +/// +/// +/// +/// A different thing from a DodoSSH identity fingerprint, and deliberately kept in the format +/// users already recognise: SHA256: followed by unpadded standard base64, exactly what +/// ssh-keygen -lf prints. A user comparing our string against the one their server +/// operator sent them must not have to wonder whether the encodings match. +/// +/// +/// Computed from the raw host key blob. SSH.NET exposes an MD5 FingerPrint property; +/// that is not used — MD5 is banned repo-wide, and no modern operator publishes an MD5 host +/// key fingerprint to compare against. +/// +/// +public static class SshHostKeyFingerprint +{ + /// Prefix identifying the hash algorithm, as OpenSSH writes it. + public const string Prefix = "SHA256:"; + + /// + /// Formats a host key blob as SHA256:<base64>. + /// + /// + /// The raw key blob as it arrived on the wire — the same bytes OpenSSH hashes, not a parsed + /// or re-encoded form. + /// + public static string Format(ReadOnlySpan hostKeyBlob) + { + if (hostKeyBlob.IsEmpty) + { + throw new ArgumentException("A host key blob is required.", nameof(hostKeyBlob)); + } + + Span digest = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(hostKeyBlob, digest); + + // Standard base64, not base64url, and unpadded — OpenSSH's exact rendering. Using the + // URL alphabet here would produce a string that looks right and never matches. + return Prefix + Convert.ToBase64String(digest).TrimEnd('='); + } + + /// + /// Compares two fingerprints in constant time. + /// + /// + /// A fingerprint is not a secret, so this is not about a timing oracle. It is about refusing + /// to let a mismatch be decided by ordinal string comparison somewhere that later gets + /// "optimised" into a case-insensitive or culture-aware comparison — base64 is + /// case-sensitive, and a case-insensitive match here would accept a different key. + /// + public static bool Equal(string? left, string? right) + { + if (left is null || right is null || left.Length != right.Length) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals( + System.Text.Encoding.UTF8.GetBytes(left), + System.Text.Encoding.UTF8.GetBytes(right)); + } +} diff --git a/src/DodoSSH.Client.Ssh/packages.lock.json b/src/DodoSSH.Client.Ssh/packages.lock.json new file mode 100644 index 0000000..70f41aa --- /dev/null +++ b/src/DodoSSH.Client.Ssh/packages.lock.json @@ -0,0 +1,48 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "SSH.NET": { + "type": "Direct", + "requested": "[2025.1.0, )", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "BouncyCastle.Cryptography": { + "type": "CentralTransitive", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Api.Tests/packages.lock.json b/tests/DodoSSH.Api.Tests/packages.lock.json index ed006cb..a73efe1 100644 --- a/tests/DodoSSH.Api.Tests/packages.lock.json +++ b/tests/DodoSSH.Api.Tests/packages.lock.json @@ -1429,15 +1429,6 @@ "resolved": "1.0.5", "contentHash": "LaSDYOJDh2WncgRboqiWtk/Igqoim/LV7v808qBeWY/f36Ol5oEKguEYpKrWw5ap8KYP0SRXf7/v3zil9koY6Q==" }, - "SSH.NET": { - "type": "Transitive", - "resolved": "2025.1.0", - "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", - "dependencies": { - "BouncyCastle.Cryptography": "2.6.2", - "Microsoft.Extensions.Logging.Abstractions": "8.0.3" - } - }, "Stef.Validation": { "type": "Transitive", "resolved": "0.3.0", @@ -1470,18 +1461,6 @@ "System.CodeDom": "6.0.0" } }, - "Testcontainers": { - "type": "Transitive", - "resolved": "4.13.0", - "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==", - "dependencies": { - "Docker.DotNet.Enhanced": "4.3.3", - "Docker.DotNet.Enhanced.X509": "4.3.3", - "Microsoft.Extensions.Logging.Abstractions": "8.0.3", - "SSH.NET": "2025.1.0", - "SharpZipLib": "1.4.2" - } - }, "TinyMapper.Signed": { "type": "Transitive", "resolved": "4.0.0", @@ -1803,6 +1782,29 @@ "dependencies": { "libsodium": "[1.0.22, 1.0.23)" } + }, + "SSH.NET": { + "type": "CentralTransitive", + "requested": "[2025.1.0, )", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Testcontainers": { + "type": "CentralTransitive", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.3.3", + "Docker.DotNet.Enhanced.X509": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } } } } diff --git a/tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj b/tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj new file mode 100644 index 0000000..e254a99 --- /dev/null +++ b/tests/DodoSSH.Client.Ssh.Tests/DodoSSH.Client.Ssh.Tests.csproj @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + diff --git a/tests/DodoSSH.Client.Ssh.Tests/PtyAndResizeSpikeTests.cs b/tests/DodoSSH.Client.Ssh.Tests/PtyAndResizeSpikeTests.cs new file mode 100644 index 0000000..f2358f5 --- /dev/null +++ b/tests/DodoSSH.Client.Ssh.Tests/PtyAndResizeSpikeTests.cs @@ -0,0 +1,249 @@ +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using Renci.SshNet; +using Renci.SshNet.Common; + +namespace DodoSSH.Client.Ssh.Tests; + +/// +/// The M1 client gate: does a PTY resize actually reach the remote? +/// +/// +/// +/// The plan flagged window-change on ShellStream as unverified, and it decides the +/// terminal's whole design — a terminal that cannot resize is unusable, and the fallback is +/// dropping to IChannelSession and driving the channel requests directly. SSH.NET 2025.1.0 +/// does expose ChangeWindowSize, but a method existing is not the remote observing it, so +/// these read the size back from the server rather than asserting the call did not throw. +/// +/// +/// Kept as a permanent regression suite rather than deleted after the spike. An SSH.NET upgrade +/// that silently stopped sending the request would otherwise ship, and the symptom — wrapped +/// output only after the user resizes the window — is easy to blame on the terminal emulator. +/// +/// +[Collection(SshCollection.Name)] +public sealed class PtyAndResizeSpikeTests(SshServerFixture fixture) +{ + private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(20); + + /// + /// Matches busybox stty size output: rows then columns. + /// + /// + /// Surrounding horizontal whitespace is tolerated, and the caller normalises CR to LF first. + /// A PTY emits CRLF, so an anchored pattern that does not account for the CR fails on output + /// that is visibly correct — which reads as "the resize did not work" and sends you looking in + /// entirely the wrong place. + /// + private static readonly Regex SizeLine = new( + @"^[ \t]*(?[0-9]{1,5})[ \t]+(?[0-9]{1,5})[ \t]*$", + RegexOptions.Multiline | RegexOptions.ExplicitCapture, + TimeSpan.FromSeconds(1)); + + [Fact] + public async Task PasswordAuthentication_Connects() + { + using var client = CreatePasswordClient(); + + await client.ConnectAsync(TestContext.Current.CancellationToken); + + client.IsConnected.ShouldBeTrue(); + } + + [Fact] + public async Task PublicKeyAuthentication_Connects() + { + // PKCS#1 PEM ("BEGIN RSA PRIVATE KEY"), which SSH.NET has read for years. The vault stores + // key material in its own format, so this is only about SSH.NET accepting a standard PEM. + var pem = fixture.ClientKey.ExportRSAPrivateKeyPem(); + using var keyStream = new MemoryStream(Encoding.ASCII.GetBytes(pem)); + using var privateKey = new PrivateKeyFile(keyStream); + + using var client = new SshClient( + new ConnectionInfo( + fixture.Host, + fixture.Port, + SshServerFixture.Username, + new PrivateKeyAuthenticationMethod(SshServerFixture.Username, privateKey))); + + await client.ConnectAsync(TestContext.Current.CancellationToken); + + client.IsConnected.ShouldBeTrue(); + } + + [Fact] + public async Task TheHostKey_ArrivesAsARawBlobWeCanFingerprint() + { + // Known-hosts entries are a synced vault entity, so the fingerprint we store has to be + // computed from the wire blob rather than read off a library property. This confirms the + // blob is exposed at all, and that our formatting matches OpenSSH's shape. + using var client = CreatePasswordClient(); + + byte[]? blob = null; + string? algorithm = null; + + client.HostKeyReceived += (_, e) => + { + blob = e.HostKey; + algorithm = e.HostKeyName; + }; + + await client.ConnectAsync(TestContext.Current.CancellationToken); + + blob.ShouldNotBeNull(); + blob.Length.ShouldBeGreaterThan(0); + algorithm.ShouldNotBeNullOrEmpty(); + + var fingerprint = SshHostKeyFingerprint.Format(blob); + + fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix); + fingerprint.ShouldNotEndWith("="); + + // 32 bytes of SHA-256 render as 43 unpadded base64 characters. + fingerprint.Length.ShouldBe(SshHostKeyFingerprint.Prefix.Length + 43); + } + + [Fact] + public async Task RefusingTheHostKey_AbortsTheConnection() + { + // The mismatch path has to be a hard block, not a warning. If CanTrust were advisory the + // TOFU dialog would be decoration. + using var client = CreatePasswordClient(); + + client.HostKeyReceived += (_, e) => e.CanTrust = false; + + await Should.ThrowAsync( + async () => await client.ConnectAsync(TestContext.Current.CancellationToken)); + + client.IsConnected.ShouldBeFalse(); + } + + [Fact] + public async Task APtyShell_ReportsTheSizeItWasCreatedWith() + { + using var client = CreatePasswordClient(); + await client.ConnectAsync(TestContext.Current.CancellationToken); + + using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096); + + var (rows, columns) = await ReadSizeAsync(shell); + + rows.ShouldBe(24); + columns.ShouldBe(80); + } + + [Fact] + public async Task ChangeWindowSize_IsObservedByTheRemote() + { + // The gate. Reads the size back from the server after resizing, so this fails if + // ChangeWindowSize silently does nothing. + using var client = CreatePasswordClient(); + await client.ConnectAsync(TestContext.Current.CancellationToken); + + using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096); + + var initial = await ReadSizeAsync(shell); + initial.ShouldBe((24, 80)); + + shell.ChangeWindowSize(columns: 132, rows: 43, width: 1320, height: 1075); + + var resized = await ReadSizeAsync(shell); + + resized.ShouldBe( + (43, 132), + "The remote did not observe the window-change request. ShellStream.ChangeWindowSize is " + + "not usable, and the terminal must drive IChannelSession directly instead."); + } + + [Fact] + public async Task ChangeWindowSize_CanBeCalledRepeatedly() + { + // A user dragging a window edge produces a stream of these. If only the first took effect, + // the terminal would settle on whatever size the drag started at. + using var client = CreatePasswordClient(); + await client.ConnectAsync(TestContext.Current.CancellationToken); + + using var shell = client.CreateShellStream("xterm-256color", 80, 24, 800, 600, 4096); + await ReadSizeAsync(shell); + + foreach (var (columns, rows) in new[] { (100u, 30u), (120u, 40u), (90u, 25u) }) + { + shell.ChangeWindowSize(columns, rows, columns * 10, rows * 25); + + var observed = await ReadSizeAsync(shell); + observed.ShouldBe(((int)rows, (int)columns)); + } + } + + // ---- Helpers ---- + + private SshClient CreatePasswordClient() => + new(fixture.Host, fixture.Port, SshServerFixture.Username, SshServerFixture.Password); + + /// Asks the remote what size it thinks the terminal is. + /// + /// The marker is written split — "MAR""KER" — so the PTY's echo of the command line + /// does not contain it. Without that the read completes on the echo and never sees the output, + /// which looks exactly like a resize that did not take effect. + /// + private static async Task<(int Rows, int Columns)> ReadSizeAsync(ShellStream shell) + { + shell.WriteLine("""stty size; echo "MAR""KER" """); + + var output = (await ReadUntilAsync(shell, "MARKER")).Replace('\r', '\n'); + + var matches = SizeLine.Matches(output); + matches.Count.ShouldBeGreaterThan(0, $"No 'rows cols' line in output:\n{output}"); + + // The last match, not the first. A read can begin with output still buffered from the + // previous command, including its size line — taking the first match would report the size + // from before the resize and turn a working resize into a failing test. + var match = matches[^1]; + + return ( + int.Parse(match.Groups["rows"].Value, CultureInfo.InvariantCulture), + int.Parse(match.Groups["columns"].Value, CultureInfo.InvariantCulture)); + } + + /// + /// Raced against a timeout rather than cancelled. ShellStream does not override + /// ReadAsync, so the base implementation runs the blocking read on a pool thread and + /// cannot observe a token once it has started — passing one would produce a test that hangs + /// instead of failing. The abandoned read dies with the process. + /// + private static async Task ReadUntilAsync(ShellStream shell, string marker) + { + var accumulated = new StringBuilder(); + var deadline = TimeProvider.System.GetUtcNow() + ReadTimeout; + + while (TimeProvider.System.GetUtcNow() < deadline) + { + var buffer = new byte[4096]; + var read = shell.ReadAsync(buffer.AsMemory()).AsTask(); + + var remaining = deadline - TimeProvider.System.GetUtcNow(); + if (remaining <= TimeSpan.Zero || await Task.WhenAny(read, Task.Delay(remaining)) != read) + { + break; + } + + var count = await read; + if (count == 0) + { + break; + } + + accumulated.Append(Encoding.UTF8.GetString(buffer, 0, count)); + + if (accumulated.ToString().Contains(marker, StringComparison.Ordinal)) + { + return accumulated.ToString(); + } + } + + throw new TimeoutException( + $"Did not see '{marker}' within {ReadTimeout}. Output so far:\n{accumulated}"); + } +} diff --git a/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs new file mode 100644 index 0000000..d50eace --- /dev/null +++ b/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs @@ -0,0 +1,132 @@ +using System.Security.Cryptography; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Containers; +using Xunit; + +namespace DodoSSH.Client.Ssh.Tests; + +/// +/// A real OpenSSH server in a container, with password and public-key auth both enabled. +/// +/// +/// +/// One container per assembly. Everything the SSH layer needs to be right about — PTY +/// allocation, window-change requests, host key encoding, key formats — is behaviour of a real +/// sshd, and none of it can be established against a mock. +/// +/// +/// The image is Alpine-based, so stty comes from busybox. Its stty size prints +/// "rows cols", which is what the resize assertions read. +/// +/// +public sealed class SshServerFixture : IAsyncLifetime +{ + /// The account tests authenticate as. + public const string Username = "dodo"; + + /// Password for password authentication. + public const string Password = "correct-horse-battery-staple"; + + private const int SshPort = 2222; + + private IContainer? container; + + /// Host port the container's sshd is published on. + public ushort Port => container!.GetMappedPublicPort(SshPort); + + /// Host the container is reachable at. + public string Host => container!.Hostname; + + /// An RSA key pair whose public half is authorized on the server. + public RSA ClientKey { get; } = RSA.Create(3072); + + /// + public async ValueTask InitializeAsync() + { + container = new ContainerBuilder("linuxserver/openssh-server:latest") + .WithEnvironment("PUID", "1000") + .WithEnvironment("PGID", "1000") + .WithEnvironment("USER_NAME", Username) + .WithEnvironment("USER_PASSWORD", Password) + .WithEnvironment("PASSWORD_ACCESS", "true") + .WithEnvironment("SUDO_ACCESS", "false") + .WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey)) + .WithPortBinding(SshPort, assignRandomHostPort: true) + + // The entrypoint generates host keys, rewrites sshd_config and installs the authorized + // key before sshd is usable, so a published port is not readiness. Both conditions are + // needed: the log line proves the authorized key was installed (a connection accepted + // before that fails public-key auth), and the port check proves sshd is actually + // accepting. The message is this image's own wording — "Server listening on" is + // OpenSSH's and never appears here, which is a wait that hangs rather than fails. + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilMessageIsLogged("Public key from env variable added") + .UntilCommandIsCompleted("sh", "-c", $"netstat -ltn | grep -q ':{SshPort}'")) + + .Build(); + + await container.StartAsync(); + } + + /// + public async ValueTask DisposeAsync() + { + if (container is not null) + { + await container.DisposeAsync(); + } + + ClientKey.Dispose(); + } + + /// + /// Renders an RSA public key in the single-line authorized_keys format. + /// + /// + /// Hand-encoded because there is no BCL helper. The SSH wire format is a sequence of + /// length-prefixed strings: the algorithm name, then the exponent, then the modulus — both + /// as signed big-endian integers, which is why a leading zero byte is prepended when the + /// high bit is set. Getting that wrong yields a key sshd silently ignores. + /// + private static string ExportOpenSshPublicKey(RSA rsa) + { + var parameters = rsa.ExportParameters(includePrivateParameters: false); + + using var blob = new MemoryStream(); + WriteSshString(blob, "ssh-rsa"u8.ToArray()); + WriteSshMpint(blob, parameters.Exponent!); + WriteSshMpint(blob, parameters.Modulus!); + + return $"ssh-rsa {Convert.ToBase64String(blob.ToArray())} dodossh-test"; + } + + private static void WriteSshString(Stream destination, byte[] value) + { + Span length = stackalloc byte[4]; + System.Buffers.Binary.BinaryPrimitives.WriteUInt32BigEndian(length, (uint)value.Length); + destination.Write(length); + destination.Write(value); + } + + private static void WriteSshMpint(Stream destination, byte[] value) + { + // Signed big-endian: a high bit set would otherwise read as negative. + if (value.Length > 0 && (value[0] & 0x80) != 0) + { + var padded = new byte[value.Length + 1]; + value.CopyTo(padded, 1); + WriteSshString(destination, padded); + return; + } + + WriteSshString(destination, value); + } +} + +/// Shares one SSH server across every test class in the assembly. +[CollectionDefinition(Name)] +public sealed class SshCollection : ICollectionFixture +{ + /// Collection name. + public const string Name = "ssh"; +} diff --git a/tests/DodoSSH.Client.Ssh.Tests/packages.lock.json b/tests/DodoSSH.Client.Ssh.Tests/packages.lock.json new file mode 100644 index 0000000..17238da --- /dev/null +++ b/tests/DodoSSH.Client.Ssh.Tests/packages.lock.json @@ -0,0 +1,313 @@ +{ + "version": 2, + "dependencies": { + "net10.0": { + "Meziantou.Analyzer": { + "type": "Direct", + "requested": "[3.0.134, )", + "resolved": "3.0.134", + "contentHash": "tTYCcYKyOko3TMNxmxmA9nakbcHVUgglENmCMIhzIjl9y9FBZO/0tWSxTGC74Sp198FmWih5S5KkjQRBg5ePkQ==" + }, + "Microsoft.CodeAnalysis.BannedApiAnalyzers": { + "type": "Direct", + "requested": "[5.6.0, )", + "resolved": "5.6.0", + "contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw==" + }, + "NSubstitute": { + "type": "Direct", + "requested": "[6.0.0, )", + "resolved": "6.0.0", + "contentHash": "0gvKMbiJ+/WrfbcfBfqRZZrvfLJcd3rqkqVMjjlY5dtmLRVzMY+o/K/rJUStofQ2haSr9Vd04YDfvZtVVGS3/A==", + "dependencies": { + "Castle.Core": "5.1.1" + } + }, + "Shouldly": { + "type": "Direct", + "requested": "[4.3.0, )", + "resolved": "4.3.0", + "contentHash": "sDetrWXrl6YXZ4HeLsdBoNk3uIa7K+V4uvIJ+cqdRa5DrFxeTED7VkjoxCuU1kJWpUuBDZz2QXFzSxBtVXLwRQ==", + "dependencies": { + "DiffEngine": "11.3.0", + "EmptyFiles": "4.4.0" + } + }, + "SSH.NET": { + "type": "Direct", + "requested": "[2025.1.0, )", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Testcontainers": { + "type": "Direct", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.3.3", + "Docker.DotNet.Enhanced.X509": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } + }, + "xunit.v3": { + "type": "Direct", + "requested": "[3.2.2, )", + "resolved": "3.2.2", + "contentHash": "L+4/4y0Uqcg8/d6hfnxhnwh4j9FaeULvefTwrk30rr1o4n/vdPfyUQ8k0yzH8VJx7bmFEkDdcRfbtbjEHlaYcA==", + "dependencies": { + "xunit.v3.mtp-v1": "[3.2.2]" + } + }, + "Castle.Core": { + "type": "Transitive", + "resolved": "5.1.1", + "contentHash": "rpYtIczkzGpf+EkZgDr9CClTdemhsrwA/W5hMoPjLkRFnXzH44zDLoovXeKtmxb1ykXK9aJVODSpiJml8CTw2g==", + "dependencies": { + "System.Diagnostics.EventLog": "6.0.0" + } + }, + "DiffEngine": { + "type": "Transitive", + "resolved": "11.3.0", + "contentHash": "k0ZgZqd09jLZQjR8FyQbSQE86Q7QZnjEzq1LPHtj1R2AoWO8sjV5x+jlSisL7NZAbUOI4y+7Bog8gkr9WIRBGw==", + "dependencies": { + "EmptyFiles": "4.4.0", + "System.Management": "6.0.1" + } + }, + "Docker.DotNet.Enhanced": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "nGicLwvd42FhRk+khY5uS6cx49ErNdwYKnYBg0F4m4BDKLp/R77AVmmN9xAiqI3W/wN5ZCHkdUhgxf5ORkZuFQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3", + "Docker.DotNet.Enhanced.LegacyHttp": "4.3.3", + "Docker.DotNet.Enhanced.NPipe": "4.3.3", + "Docker.DotNet.Enhanced.NativeHttp": "4.3.3", + "Docker.DotNet.Enhanced.Unix": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.Handler.Abstractions": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "9Cp8hOgtynixcDoAs9lnEaQosluojSYmiW3fsLsLIVfZjlq/fznSIZNUhnmyT4Xo1Iyuok/y49WL/25O47u0Pw==", + "dependencies": { + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Docker.DotNet.Enhanced.LegacyHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "7j3M16emv9PAQN7VwFn23xLYNj8GJmwPOcogveHkaWnOCqiC+anRaNKQwqIBNApM1AuwZKivehTKTPmmrjUUnw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NativeHttp": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "iNzK+jRFeEMobSA7l/h4ARwCKOOefOWtVN5/RB0ft6/6H6IQXvVUuOgGyZAjYLBT7TsyClRYno2B904f3dtBuQ==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.NPipe": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ZTLYufuEfY0e6qLOgeH9QgXx2KYuoABRVaY5A8rsggyLgYqbDj9rCRfVAhHPCUv83S7pVxDHy+Tvm/BnxjWVpg==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.Unix": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "ypo8qNbmvHw1t9VfpRTMogCw2vht6VjkXzlGYUUeP2H2bf83USURdla1maW1njn2oq2rfLUFOGMfmt+A37QU2w==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "Docker.DotNet.Enhanced.X509": { + "type": "Transitive", + "resolved": "4.3.3", + "contentHash": "oBDibWezEv4hgj3RIQxI3DVcxkNV1MdrD0d/jhjUu+h3DL+qc0wlkQva15kkwMatXmC/hWp1VP0DMoFXe+BmEw==", + "dependencies": { + "Docker.DotNet.Enhanced.Handler.Abstractions": "4.3.3" + } + }, + "EmptyFiles": { + "type": "Transitive", + "resolved": "4.4.0", + "contentHash": "gwJEfIGS7FhykvtZoscwXj/XwW+mJY6UbAZk+qtLKFUGWC95kfKXnj8VkxsZQnWBxJemM/q664rGLN5nf+OHZw==" + }, + "Microsoft.ApplicationInsights": { + "type": "Transitive", + "resolved": "2.23.0", + "contentHash": "nWArUZTdU7iqZLycLKWe0TDms48KKGE6pONH2terYNa8REXiqixrMOkf1sk5DHGMaUTqONU2YkS4SAXBhLStgw==" + }, + "Microsoft.Bcl.AsyncInterfaces": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "UcSjPsst+DfAdJGVDsu346FX0ci0ah+lw3WRtn18NUwEqRt70HaOQ7lI72vy3+1LxtqI3T5GWwV39rQSrCzAeg==" + }, + "Microsoft.Extensions.DependencyInjection.Abstractions": { + "type": "Transitive", + "resolved": "8.0.2", + "contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg==" + }, + "Microsoft.Extensions.Logging.Abstractions": { + "type": "Transitive", + "resolved": "8.0.3", + "contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==", + "dependencies": { + "Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2" + } + }, + "Microsoft.Testing.Extensions.Telemetry": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "No5AudZMmSb+uNXjlgL2y3/stHD2IT4uxqc5yHwkE+/nNux9jbKcaJMvcp9SwgP4DVD8L9/P3OUz8mmmcvEIdQ==", + "dependencies": { + "Microsoft.ApplicationInsights": "2.23.0", + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Extensions.TrxReport.Abstractions": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "AL46Xe1WBi85Ntd4mNPvat5ZSsZ2uejiVqoKCypr8J3wK0elA5xJ3AN4G/Q4GIwzUFnggZoH/DBjnr9J18IO/g==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Testing.Platform": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "QafNtNSmEI0zazdebnsIkDKmFtTSpmx/5PLOjURWwozcPb3tvRxzosQSL8xwYNM1iPhhKiBksXZyRSE2COisrA==" + }, + "Microsoft.Testing.Platform.MSBuild": { + "type": "Transitive", + "resolved": "1.9.1", + "contentHash": "oTUtyR4X/s9ytuiNA29FGsNCCH0rNmY5Wdm14NCKLjTM1cT9edVSlA+rGS/mVmusPqcP0l/x9qOnMXg16v87RQ==", + "dependencies": { + "Microsoft.Testing.Platform": "1.9.1" + } + }, + "Microsoft.Win32.Registry": { + "type": "Transitive", + "resolved": "5.0.0", + "contentHash": "dDoKi0PnDz31yAyETfRntsLArTlVAVzUzCIvvEDsDsucrl33Dl8pIJG06ePTJTI3tGpeyHS9Cq7Foc/s4EeKcg==" + }, + "SharpZipLib": { + "type": "Transitive", + "resolved": "1.4.2", + "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" + }, + "System.CodeDom": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "CPc6tWO1LAer3IzfZufDBRL+UZQcj5uS207NHALQzP84Vp/z6wF0Aa0YZImOQY8iStY0A2zI/e3ihKNPfUm8XA==" + }, + "System.Diagnostics.EventLog": { + "type": "Transitive", + "resolved": "6.0.0", + "contentHash": "lcyUiXTsETK2ALsZrX+nWuHSIQeazhqPphLfaRxzdGaG93+0kELqpgEHtwWOlQe7+jSFnKwaCAgL4kjeZCQJnw==" + }, + "System.Management": { + "type": "Transitive", + "resolved": "6.0.1", + "contentHash": "10J1D0h/lioojphfJ4Fuh5ZUThT/xOVHdV9roGBittKKNP2PMjrvibEdbVTGZcPra1399Ja3tqIJLyQrc5Wmhg==", + "dependencies": { + "System.CodeDom": "6.0.0" + } + }, + "xunit.analyzers": { + "type": "Transitive", + "resolved": "1.27.0", + "contentHash": "y/pxIQaLvk/kxAoDkZW9GnHLCEqzwl5TW0vtX3pweyQpjizB9y3DXhb9pkw2dGeUqhLjsxvvJM1k89JowU6z3g==" + }, + "xunit.v3.assert": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "BPciBghgEEaJN/JG00QfCYDfEfnLgQhfnYEy+j1izoeHVNYd5+3Wm8GJ6JgYysOhpBPYGE+sbf75JtrRc7jrdA==" + }, + "xunit.v3.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Hj775PEH6GTbbg0wfKRvG2hNspDCvTH9irXhH4qIWgdrOSV1sQlqPie+DOvFeigsFg2fxSM3ZAaaCDQs+KreFA==", + "dependencies": { + "Microsoft.Bcl.AsyncInterfaces": "6.0.0" + } + }, + "xunit.v3.core.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "Ga5aA2Ca9ktz+5k3g5ukzwfexwoqwDUpV6z7atSEUvqtd6JuybU1XopHqg1oFd78QdTfZgZE9h5sHpO4qYIi5w==", + "dependencies": { + "Microsoft.Testing.Extensions.Telemetry": "1.9.1", + "Microsoft.Testing.Extensions.TrxReport.Abstractions": "1.9.1", + "Microsoft.Testing.Platform": "1.9.1", + "Microsoft.Testing.Platform.MSBuild": "1.9.1", + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.inproc.console": "[3.2.2]" + } + }, + "xunit.v3.extensibility.core": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "srY8z/oMPvh/t8axtO2DwrHajhFMH7tnqKildvYrVQIfICi8fOn3yIBWkVPAcrKmHMwvXRJ/XsQM3VMR6DOYfQ==", + "dependencies": { + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.mtp-v1": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "O41aAzYKBT5PWqATa1oEWVNCyEUypFQ4va6K0kz37dduV3EKzXNMaV2UnEhufzU4Cce1I33gg0oldS8tGL5I0A==", + "dependencies": { + "xunit.analyzers": "1.27.0", + "xunit.v3.assert": "[3.2.2]", + "xunit.v3.core.mtp-v1": "[3.2.2]" + } + }, + "xunit.v3.runner.common": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "/hkHkQCzGrugelOAehprm7RIWdsUFVmIVaD6jDH/8DNGCymTlKKPTbGokD5czbAfqfex47mBP0sb0zbHYwrO/g==", + "dependencies": { + "Microsoft.Win32.Registry": "[5.0.0]", + "xunit.v3.common": "[3.2.2]" + } + }, + "xunit.v3.runner.inproc.console": { + "type": "Transitive", + "resolved": "3.2.2", + "contentHash": "ulWOdSvCk+bPXijJZ73bth9NyoOHsAs1ZOvamYbCkD4DNLX/Bd29Ve2ZNUwBbK0MqfIYWXHZViy/HKrdEC/izw==", + "dependencies": { + "xunit.v3.extensibility.core": "[3.2.2]", + "xunit.v3.runner.common": "[3.2.2]" + } + }, + "dodossh.client.ssh": { + "type": "Project", + "dependencies": { + "SSH.NET": "[2025.1.0, )" + } + }, + "BouncyCastle.Cryptography": { + "type": "CentralTransitive", + "requested": "[2.6.2, )", + "resolved": "2.6.2", + "contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w==" + } + } + } +} \ No newline at end of file diff --git a/tests/DodoSSH.Infrastructure.Tests/packages.lock.json b/tests/DodoSSH.Infrastructure.Tests/packages.lock.json index 8382bd2..8a8cea6 100644 --- a/tests/DodoSSH.Infrastructure.Tests/packages.lock.json +++ b/tests/DodoSSH.Infrastructure.Tests/packages.lock.json @@ -286,15 +286,6 @@ "resolved": "1.4.2", "contentHash": "yjj+3zgz8zgXpiiC3ZdF/iyTBbz2fFvMxZFEBPUcwZjIvXOf37Ylm+K58hqMfIBt5JgU/Z2uoUS67JmTLe973A==" }, - "SSH.NET": { - "type": "Transitive", - "resolved": "2025.1.0", - "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", - "dependencies": { - "BouncyCastle.Cryptography": "2.6.2", - "Microsoft.Extensions.Logging.Abstractions": "8.0.3" - } - }, "System.CodeDom": { "type": "Transitive", "resolved": "6.0.0", @@ -313,18 +304,6 @@ "System.CodeDom": "6.0.0" } }, - "Testcontainers": { - "type": "Transitive", - "resolved": "4.13.0", - "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==", - "dependencies": { - "Docker.DotNet.Enhanced": "4.3.3", - "Docker.DotNet.Enhanced.X509": "4.3.3", - "Microsoft.Extensions.Logging.Abstractions": "8.0.3", - "SSH.NET": "2025.1.0", - "SharpZipLib": "1.4.2" - } - }, "xunit.analyzers": { "type": "Transitive", "resolved": "1.27.0", @@ -443,6 +422,29 @@ "Microsoft.Extensions.Configuration.Abstractions": "10.0.10", "Microsoft.Extensions.Logging": "10.0.10" } + }, + "SSH.NET": { + "type": "CentralTransitive", + "requested": "[2025.1.0, )", + "resolved": "2025.1.0", + "contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==", + "dependencies": { + "BouncyCastle.Cryptography": "2.6.2", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3" + } + }, + "Testcontainers": { + "type": "CentralTransitive", + "requested": "[4.13.0, )", + "resolved": "4.13.0", + "contentHash": "j8vi9jPBNSwaraGGx8w+2gtZyWrlbKxdhiGMS3nektg+KiwjFWx9ghCjs57EoQfvI+IAbzti0oQJupQChwgMog==", + "dependencies": { + "Docker.DotNet.Enhanced": "4.3.3", + "Docker.DotNet.Enhanced.X509": "4.3.3", + "Microsoft.Extensions.Logging.Abstractions": "8.0.3", + "SSH.NET": "2025.1.0", + "SharpZipLib": "1.4.2" + } } } }