Files
DodoSSH/tests/DodoSSH.Client.Ssh.Tests/SshServerFixture.cs
T
jaap-jan 885fb17bdc 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.
2026-07-28 16:52:09 +02:00

133 lines
5.1 KiB
C#

using System.Security.Cryptography;
using DotNet.Testcontainers.Builders;
using DotNet.Testcontainers.Containers;
using Xunit;
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// A real OpenSSH server in a container, with password and public-key auth both enabled.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// The image is Alpine-based, so <c>stty</c> comes from busybox. Its <c>stty size</c> prints
/// "rows cols", which is what the resize assertions read.
/// </para>
/// </remarks>
public sealed class SshServerFixture : IAsyncLifetime
{
/// <summary>The account tests authenticate as.</summary>
public const string Username = "dodo";
/// <summary>Password for password authentication.</summary>
public const string Password = "correct-horse-battery-staple";
private const int SshPort = 2222;
private IContainer? container;
/// <summary>Host port the container's sshd is published on.</summary>
public ushort Port => container!.GetMappedPublicPort(SshPort);
/// <summary>Host the container is reachable at.</summary>
public string Host => container!.Hostname;
/// <summary>An RSA key pair whose public half is authorized on the server.</summary>
public RSA ClientKey { get; } = RSA.Create(3072);
/// <inheritdoc />
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();
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (container is not null)
{
await container.DisposeAsync();
}
ClientKey.Dispose();
}
/// <summary>
/// Renders an RSA public key in the single-line <c>authorized_keys</c> format.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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<byte> 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);
}
}
/// <summary>Shares one SSH server across every test class in the assembly.</summary>
[CollectionDefinition(Name)]
public sealed class SshCollection : ICollectionFixture<SshServerFixture>
{
/// <summary>Collection name.</summary>
public const string Name = "ssh";
}