using System.Net.Sockets; using System.Security.Cryptography; using System.Text; 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; /// /// How many connections in a row the server has to answer before this fixture calls it ready. /// /// /// Twenty-five, and the number is measured rather than picked. Probing a fresh container 200 times with /// penalties left at the image's default, the first Not allowed at this time came back at probe /// 18 and 183 of the 200 were refused; with PerSourcePenalties no applied, none of 200 were. Ten /// was tried first and is useless — it sits below the threshold, so the guard passed happily against a /// server that was still penalising. See . /// private const int RequiredStreak = 25; /// How long to keep trying before giving up on the server entirely. private static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(60); 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 ReconfigureAsync(); await WaitUntilServingAsync(); } /// /// Turns off the hardening this suite trips over, and makes the running server re-read its config. /// /// /// /// ◆ PerSourcePenalties no is the fix for the flake this suite had for months, and the other /// two settings here are not. OpenSSH 9.8 added per-source penalties and 10.x has them on by /// default; this image runs 10.3. A source address that keeps disconnecting without authenticating is /// penalised, and while the penalty holds every connection from it is answered with the clear-text line /// Not allowed at this time and then closed. /// /// /// This suite generates exactly that traffic, by design. This client's first contact with an /// unknown host is a connection deliberately refused at the host key — which is a disconnect with no /// authentication attempt — and several tests do nothing else: /// RefusingTheHostKey_AbortsTheConnection, AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe, /// and every helper that learns a host key by being turned away first. Enough of them close together and /// sshd stops talking to the test host altogether, for a while, and then starts again. /// /// /// From the client that is SshConnectionException: The connection was closed by the remote host /// within milliseconds — no banner, nothing to say which of the many reasons it was. It hits whichever /// class is running when the penalty lands and spares the rest, which is why it read as random and why /// the class it hit lost every connection it made rather than a random few. The one test in that /// class that expects a refusal passed throughout, for the wrong reason. /// /// /// ◆ Two earlier diagnoses were wrong, and are recorded here so they are not tried again. /// MaxStartups was blamed on the reasoning that xUnit runs test classes in parallel, so ten /// unauthenticated connections would be in flight at once — but every class that touches this server /// shares , and xUnit's unit of parallelism is the collection, so they run one /// after another and never have more than a connection or two open. The reload window was blamed next, /// and a wait for the banner to answer was written and removed as unproven; it was unproven because the /// banner does answer, right up until the penalty lands. /// /// /// The line is appended rather than replaced in place, unlike the two below it, because the image's /// config does not mention the keyword at all — there is no line to replace, and sshd takes the first /// value it finds for a keyword that appears more than once. /// /// /// ◆ AllowTcpForwarding is what a dynamic forward needs, and the image ships it off as /// hardening. Without it a forward opens perfectly happily — a local listener 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, which names neither the server nor the setting, and is what the first /// run of LoopbackProxyTests collected. That suite is also the alarm if this method ever silently /// stops working. /// /// /// MaxStartups is raised for the reason it should have been in the first place rather than as a /// fix for anything: the compiled-in default refuses connections at random past ten unauthenticated ones /// in flight, and a throttle is hardening a test server has no business reproducing. It is kept, not /// because it was ever shown to matter here, but because removing it would be a second change riding /// along with this one. /// /// /// Both are replaced in place rather than appended, because sshd_config takes the first value /// it finds for a keyword: an appended line would be dead the day the image ships an uncommented one of /// its own. /// /// /// ◆ /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. /// /// /// ◆ Patched after boot and reloaded, rather than injected before it — which was tried and does not /// work. This image family runs /custom-cont-init.d scripts, which look like the right hook /// and are not: the container's own log puts sshd is listening on port 2222 before /// [custom-init] Files found, executing, so a script there edits a file the running server has /// already read. It leaves a config that greps correctly and a server behaving as though it had never /// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version /// of this fixture did exactly that and failed twenty-eight tests. /// /// private async Task ReconfigureAsync() { var result = await container!.ExecAsync([ "sh", "-c", "sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config" + " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config" + " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config" + " && pkill -HUP sshd", ]); if (result.ExitCode != 0) { throw new InvalidOperationException( $"Could not reconfigure the test server: {result.Stderr}"); } } /// /// Blocks until the server answers connections in a row with its banner. /// /// /// /// ◆ This is a guard rather than a wait, and what it guards against is /// PerSourcePenalties coming back. Reconfiguring above turns it off; this proves it is off, /// immediately and by name, instead of letting the suite discover it later as an unrelated-looking /// failure in whichever class happened to be running. /// /// /// Consecutive, and deliberately with no pause between them. Each probe opens a connection, reads /// the identification string and disconnects without authenticating — which is exactly the shape of /// connection PerSourcePenalties punishes, and exactly what this suite does all day: a first /// contact with an unknown host is a connection this client deliberately refuses at the host key. /// back to back is therefore not a soak test, it is the specific /// provocation, sized above the measured threshold on purpose, and it costs well under a second when the /// setting is off. /// /// /// It is also the one check that can tell a listening socket from a running server. The container's own /// readiness — a log line and netstat showing :2222 — passes on a container whose sshd has /// gone: the socket is published by a host-side proxy that accepts before it has anything to forward to, /// so a dead server presents as a connection accepted and closed rather than as one refused. /// /// /// Probed from the host rather than with docker exec, deliberately: that is the path the tests /// take, proxy included, and penalties are counted per source address — from inside the container the /// source would be the loopback rather than the address every test connects from. /// /// private async Task WaitUntilServingAsync() { // TimeProvider.System rather than DateTimeOffset.UtcNow, which this repository bans so that time can // be faked — and rather than a fake, because what is being waited on is a real container starting. var deadline = TimeProvider.System.GetUtcNow() + ReadyTimeout; var streak = 0; var last = "no probe ran"; while (streak < RequiredStreak) { if (TimeProvider.System.GetUtcNow() >= deadline) { throw new InvalidOperationException( $"The test server did not answer {RequiredStreak} connections in a row within " + $"{ReadyTimeout}. The last probe said: {last}. If it says \"Not allowed at this " + "time\", sshd is penalising this source address and PerSourcePenalties is no longer " + "being turned off — see ReconfigureAsync."); } var (answered, what) = await ProbeAsync(); last = what; if (answered) { streak++; continue; } // Only pause when it is not working. Back-to-back probes are the point while they succeed; // hammering a server that has not finished starting is just noise. streak = 0; await Task.Delay(TimeSpan.FromMilliseconds(200)); } } /// Opens a socket and reads far enough to see OpenSSH's identification string. /// /// The description comes back with the answer because the interesting failures are not exceptions. A /// penalised source is told Not allowed at this time in clear text before the socket closes, and /// a suite that only knew "no banner" would have to go and find that out again — which is what happened /// the first time, at some length. /// private async Task<(bool Answered, string What)> ProbeAsync() { try { using var probe = new TcpClient(); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await probe.ConnectAsync(Host, Port, timeout.Token); var buffer = new byte[64]; var read = await probe.GetStream().ReadAtLeastAsync( buffer, 4, throwOnEndOfStream: false, timeout.Token); var answered = read >= 4 && "SSH-"u8.SequenceEqual(buffer.AsSpan(0, 4)); return ( answered, answered ? "SSH-" : $"{read} bytes: " + Encoding.ASCII.GetString(buffer, 0, Math.Max(read, 0)).ReplaceLineEndings(" ")); } catch (Exception exception) when (exception is SocketException or OperationCanceledException or IOException) { return (false, $"{exception.GetType().Name}: {exception.Message}"); } } /// /// 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. This was once explained as a way of staying under the server's /// MaxStartups throttle, on the belief that the suite ran its classes in parallel and made two /// handshakes per test — this client's first contact with an unknown host is a connection deliberately /// refused at the host key, so every session costs two. The parallelism was not real: every /// class here shares one collection and xUnit runs collections, not classes, in parallel. See /// , which is where that mistake was found and what the failure it /// was blamed for turned out to be. /// /// /// It stays shared regardless, on the plainer argument: one session is enough, and a handshake per test /// would be seconds of the suite's runtime spent proving nothing this file has not already proved. /// /// /// 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"; }