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"; }