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"; /// /// The port sshd listens on inside the container, rather than the one it is published on. /// /// /// What anything reaching this server from within the container's own network namespace has to use — /// which includes a forward opened on a connection to it. is the mapped port on the /// host and is not listening in there. See LoopbackProxyTests, where the difference is the whole /// shape of the test. /// public const int InternalPort = SshPort; private const int SshPort = 2222; private readonly SemaphoreSlim sftpGate = new(1, 1); private IContainer? container; private ISftpSession? sftp; /// 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(); await AllowTcpForwardingAsync(); } /// /// Lets this server open the direct-tcpip channels a forward is made of. /// /// /// /// ◆ The image ships AllowTcpForwarding no, and nothing says so at the point it bites. A /// dynamic forward starts perfectly happily — it is a local listener, and opening it asks the server /// nothing — and then every connection through it is refused when the channel is opened. SSH.NET /// reports that as SOCKS5: General failure from the proxy, which names neither the server nor /// the setting, and is what the first run of LoopbackProxyTests collected. /// /// /// Patched after start rather than baked in, because the image's entrypoint writes its configuration /// itself on every boot — a mounted file would be overwritten before sshd read it. sshd re-reads on /// SIGHUP and applies the result to connections made after that, and the readiness wait has /// already run, so nothing here races the boot. /// /// /// ◆ /config/sshd/sshd_config, and there are two. The image also carries /// /etc/ssh/sshd_config, which looks like the file to patch, reads identically, and is not the /// one the running server was started with — patching it changes the text and nothing else, which is a /// fix that appears to work and leaves the failure exactly where it was. Measured with find /// rather than assumed, after the first version of this method did precisely that. /// /// /// It is on for the whole assembly rather than for the one test that needs it. Forwarding is off in /// this image as hardening, not as a behaviour worth reproducing: nothing else here opens a channel of /// any kind, so allowing it changes what exactly one suite can do and what none of the others see. /// /// private async Task AllowTcpForwardingAsync() { var result = await container!.ExecAsync([ "sh", "-c", "sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config" + " && pkill -HUP sshd", ]); if (result.ExitCode != 0) { throw new InvalidOperationException( $"Could not enable TCP forwarding on the test server: {result.Stderr}"); } } /// /// Adds one authorized_keys line to the account tests authenticate as. /// /// /// /// The fixture's own key arrives through the image's PUBLIC_KEY variable, which takes one. This /// is for the case that needs a second: proving a key this client generated authenticates /// against a real sshd, which is the only test that can establish the hand-written /// openssh-key-v1 encoding is right. A parser accepting the file is weaker — SSH.NET could be /// forgiving about something OpenSSH is not. /// /// /// sshd re-reads authorized_keys on each authentication attempt, so nothing has to be restarted. /// /// public async ValueTask AuthorizeAsync(string publicKeyLine, CancellationToken cancellationToken) { ArgumentException.ThrowIfNullOrWhiteSpace(publicKeyLine); // Single-quoted in the shell and the line is base64 plus an algorithm name and a comment, so there // is nothing in it a quote could end. Asserted rather than assumed all the same: a silent failure // here would show up as an authentication error in a test whose subject is the key encoding, which // is the most misleading way for this to break. var result = await container!.ExecAsync( ["sh", "-c", $"echo '{publicKeyLine.Trim()}' >> /config/.ssh/authorized_keys"], cancellationToken); if (result.ExitCode != 0) { throw new InvalidOperationException( $"Could not install the public key in the container: {result.Stderr}"); } } /// /// One file-transfer session, opened on first use and shared by every test that wants one. /// /// /// /// Shared rather than opened per test, and that is a limit of the server rather than an optimisation. /// sshd's MaxStartups drops connections at random once enough are part-way through a handshake, /// and this client's first contact with an unknown host is a connection deliberately refused at /// the host key — so a suite that opened its own session per test made two handshakes per test and /// pushed the whole assembly over the threshold. What that looks like is unrelated tests failing with /// "the connection was closed by the remote host", a different few each run. /// /// /// Safe to share because an SFTP session holds no per-test state: every test here works in a directory /// named after itself. See ISftpSession, which is one channel and is used by one caller at a /// time. /// /// public async ValueTask SftpAsync(CancellationToken cancellationToken) { await sftpGate.WaitAsync(cancellationToken); try { if (sftp is not null) { return sftp; } var knownHosts = new InMemoryKnownHostStore(); var factory = new SshNetConnectionFactory(knownHosts); var request = new SshConnectionRequest( Host, Port, Username, new SshPasswordCredential(Password)); try { // Learned by being refused, which is the only way this client learns a host key. return sftp = await factory.OpenSftpAsync(request, cancellationToken); } catch (SshHostKeyUnknownException unknown) { await knownHosts.TrustAsync(unknown.Presentation, cancellationToken); } return sftp = await factory.OpenSftpAsync(request, cancellationToken); } finally { sftpGate.Release(); } } /// public async ValueTask DisposeAsync() { if (sftp is not null) { await sftp.DisposeAsync(); } sftpGate.Dispose(); 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"; }