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