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.
///
///
/// ◆ MaxStartups is raised here too, against a flake this suite has and that this change is
/// a mitigation for rather than a proven cure. The distinction is stated because the evidence
/// stops short of the claim, and a later reader deserves to know which.
///
///
/// What is established: sshd's compiled-in default is 10:30:100 — past ten
/// unauthenticated connections in flight it refuses new ones at random, thirty percent of the
/// time, rising to always at a hundred — and the image ships the line commented out, so that default
/// was what ran. xUnit runs test classes in parallel and most classes here open a connection, so ten
/// in flight is reachable in the opening seconds. A refused connection presents to the client as
/// SshConnectionException: The connection was closed by the remote host within tens of
/// milliseconds, on whichever test connects at the wrong moment — which is exactly the observed
/// failure, seen in CI and reproduced locally.
///
///
/// What is not established is that this limit is the only cause, because the flake rate could
/// not be measured reliably. On the development machine the identical unmodified suite ran 85/85 clean
/// and, an hour later, failed 13 runs out of 15 — Docker throughput on that host swings far enough to
/// swamp the effect being measured. Any before/after comparison taken there is noise, and two were,
/// before that was noticed.
///
///
/// It is committed anyway, on the narrower argument that it is right regardless: a connection throttle
/// is hardening this suite has no interest in reproducing. It exists to test an SSH client, not to
/// survive a rate limit, and a test server that drops connections at random is a bad test server
/// whether or not it is the cause of this particular flake.
///
///
/// Not fixed by serialising the suite, which would have hidden it and cost the parallelism, and
/// not by retrying the connect, which would have made the client's own reconnect behaviour untestable
/// by burying it in the fixture. The limit is a property of a hardened server that this suite has no
/// interest in reproducing — it exists to test an SSH client, not to survive a throttle.
///
///
/// 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.
///
///
/// ◆ The reload window is the other candidate, and it is deliberately not guarded against.
/// SIGHUP makes sshd close its listeners and re-execute itself, and pkill returns when
/// the signal is delivered rather than when that has finished — so in principle a connection made
/// immediately afterwards is refused, producing this same exception. A wait that opened connections
/// until the server answered with its banner three times running was written, and then removed: it
/// could not be shown to change anything either, and a fixture carrying two unproven fixes for one
/// symptom is worse than one, because the next person has to disprove both.
///
///
/// If this flake returns, that is the next thing to try. Two things to know before trying it: the two
/// causes are indistinguishable from the client, so a fix can only be judged by a repeat run and never
/// by whether the next run passes — and the repeat run has to happen somewhere with stable Docker
/// throughput, which the development machine is not. Better still, make sshd say why: raise its
/// LogLevel here, disable Ryuk so the container outlives the run, and read
/// docker logs. A MaxStartups refusal names itself there; a reload does not.
///
///
private async Task AllowTcpForwardingAsync()
{
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"
+ " && pkill -HUP sshd",
]);
if (result.ExitCode != 0)
{
throw new InvalidOperationException(
$"Could not reconfigure 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";
}