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