Public Access
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.
133 lines
5.1 KiB
C#
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";
|
|
}
|