using System.Net;
using System.Net.Sockets;
using Renci.SshNet;
namespace DodoSSH.Client.Ssh.Tests;
///
/// Reaching a host through a SOCKS5 proxy on loopback, which is how this client will reach one it cannot
/// dial: through a bastion, or through the server relay.
///
///
///
/// One container, used as both ends. The fixture's sshd is the bastion and the target — a
/// dynamic forward is opened on a connection to it, and the connection under test goes back to the same
/// server through that forward. Two containers would look more like the real topology and would test
/// nothing extra: what is being established is that the request's proxy is honoured, that the target is
/// what gets pinned, and that a failure on the way through is reported as itself. None of the three is
/// about the far end being a different machine.
///
///
/// The forward is SSH.NET's own ForwardedPortDynamic, which is what the jump-host path will use in
/// earnest — so this is not a stub standing in for the eventual proxy, it is the eventual proxy. The relay
/// will put a bridge of this repository's own on the same loopback interface and speak the same protocol
/// to it. See docs/reaching-a-host-you-cannot-dial.md.
///
///
[Collection(SshCollection.Name)]
public sealed class LoopbackProxyTests(SshServerFixture fixture)
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
///
/// The whole of what this change buys, and the property that makes it safe.
///
///
///
/// A connection through the proxy has to arrive, and it has to arrive as the target. The pin is
/// keyed on the host and port the request names, so if the proxy's address leaked into that identity
/// every machine reached through a bastion would be pinned as 127.0.0.1 on whatever ephemeral
/// port that day's forward happened to get — which is not an identity at all, and would mean a trusted
/// first contact for anything reached the same way afterwards.
///
///
/// ◆ The target is named as the bastion can reach it, not as this machine can. The forward runs
/// inside the container, so the address in the CONNECT request is resolved there —
/// localhost:2222 — and the published port this test host would use means nothing in that
/// namespace. That is not a quirk of the fixture: it is what ProxyJump means, and it is why an
/// ssh_config writes the target's *internal* address beside its jump host. Getting it wrong is a
/// SOCKS "general failure" from the bastion, which is what the first draft of this test collected.
///
///
/// Started from an empty store, so the assertion is not merely that the right string was recorded: the
/// same server is unknown under this identity until it is trusted under it, and being trusted under its
/// direct name would not do. Both halves of that are the point.
///
///
[Fact]
public async Task AHostReachedThroughAProxy_ConnectsAndIsPinnedUnderItsOwnName()
{
using var bastion = OpenBastion();
using var forward = StartDynamicForward(bastion);
var request = ThroughTheBastion(forward);
var knownHosts = new InMemoryKnownHostStore();
var factory = new SshNetConnectionFactory(knownHosts);
var unknown = await Should.ThrowAsync(async () =>
await factory.ConnectAsync(request, progress: null, Token));
unknown.Presentation.Host.ShouldBe(InternalHost, "the target's name, not the proxy's");
unknown.Presentation.Port.ShouldBe(SshServerFixture.InternalPort);
((int)forward.BoundPort).ShouldNotBe(
SshServerFixture.InternalPort, "or the two identities would be indistinguishable");
await knownHosts.TrustAsync(unknown.Presentation, Token);
await using var connection = await factory.ConnectAsync(request, progress: null, Token);
connection.IsConnected.ShouldBeTrue();
connection.HostKey.Host.ShouldBe(InternalHost);
// Authenticated is not the same as usable, and a proxied transport is exactly where a channel might
// not open: everything from here is SSH.NET's own framing over a socket it did not dial itself.
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
shell.IsOpen.ShouldBeTrue();
}
///
/// The forward binds an ephemeral port and reports it, which is the one thing about
/// ForwardedPortDynamic this code depends on and the XML documentation does not state. Held here
/// so that an SSH.NET that stopped filling it in fails by name instead of leaving the test above
/// dialling port zero and reporting a connection error.
///
[Fact]
public void ADynamicForward_ReportsThePortItWasGiven()
{
using var bastion = OpenBastion();
using var forward = StartDynamicForward(bastion);
forward.BoundHost.ShouldBe("127.0.0.1", "a SOCKS proxy on any other interface is an open proxy");
forward.BoundPort.ShouldBeGreaterThan(0u, "an ephemeral bind has to report what it got");
}
///
/// A proxy that is not there is a connection failure, and must not be dressed up as a host key problem.
///
///
/// The same misreport KeyAuthenticationTests guards for authentication, one layer lower and
/// easier to get wrong: the gate translates a refusal into on
/// the strength of having seen no host key, and a connection that never reached a server has seen none
/// either. Showing a fingerprint prompt for an unreachable bastion would offer to fix the wrong thing —
/// and there would be no fingerprint to show.
///
[Fact]
public async Task AProxyThatIsNotListening_FailsAsAConnectionErrorRatherThanAnUnknownHostKey()
{
// An empty store, so the wrong answer is available: had the connection reached a server, this is
// exactly the setup that produces SshHostKeyUnknownException. It never gets that far.
var request = Request(Credential(), new SshLoopbackProxy(DeadPort()));
var failure = await Should.ThrowAsync(async () =>
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
.ConnectAsync(request, progress: null, Token));
failure.ShouldNotBeOfType();
failure.ShouldNotBeOfType();
}
///
/// The connection every other test in this assembly makes, asserted once to be unchanged: the proxy is
/// an optional last parameter, so a request that names none has to build the connection it always did.
///
[Fact]
public async Task AHostWithNoProxy_IsStillDialledDirectly()
{
var knownHosts = await TrustedStoreAsync();
await using var connection = await new SshNetConnectionFactory(knownHosts)
.ConnectAsync(Request(Credential(), proxy: null), progress: null, Token);
connection.IsConnected.ShouldBeTrue();
}
/// A port nothing is listening on, found by binding one and letting it go.
///
/// Racy in principle and not in practice: nothing else in this process binds ephemeral ports, and the
/// consequence of losing the race is a connection that succeeds where the test wanted a refusal, which
/// fails the assertion rather than passing quietly.
///
private static int DeadPort()
{
using var probe = new TcpListener(IPAddress.Loopback, 0);
probe.Start();
var port = ((IPEndPoint)probe.LocalEndpoint).Port;
probe.Stop();
return port;
}
private SshClient OpenBastion()
{
var client = new SshClient(
fixture.Host,
fixture.Port,
SshServerFixture.Username,
SshServerFixture.Password);
client.Connect();
return client;
}
///
/// Bound to 127.0.0.1 explicitly. The single-argument constructor's default is undocumented, and
/// the failure it would produce if that default is 0.0.0.0 is not a test failure — it is a SOCKS
/// proxy into the developer's network, open for as long as the connection lives, that nothing would
/// report. The test above asserts the bound host for the same reason.
///
private static ForwardedPortDynamic StartDynamicForward(SshClient bastion)
{
var forward = new ForwardedPortDynamic("127.0.0.1", 0);
bastion.AddForwardedPort(forward);
forward.Start();
return forward;
}
/// What the container calls itself, which is the only name the forward inside it can resolve.
private const string InternalHost = "localhost";
private static SshPasswordCredential Credential() => new(SshServerFixture.Password);
private SshConnectionRequest Request(SshCredential credential, SshLoopbackProxy? proxy) =>
new(fixture.Host, fixture.Port, SshServerFixture.Username, credential, ConnectTimeout: null, proxy);
/// The same server, addressed as the machine running the forward can reach it.
private static SshConnectionRequest ThroughTheBastion(ForwardedPortDynamic forward) =>
new(
InternalHost,
SshServerFixture.InternalPort,
SshServerFixture.Username,
Credential(),
ConnectTimeout: null,
new SshLoopbackProxy((int)forward.BoundPort));
/// A store that already trusts the container's host key, so first contact is not the subject.
/// Learned by being refused, which is the only way this client learns a host key.
private async Task TrustedStoreAsync()
{
var knownHosts = new InMemoryKnownHostStore();
var unknown = await Should.ThrowAsync(async () =>
await new SshNetConnectionFactory(knownHosts)
.ConnectAsync(Request(Credential(), proxy: null), progress: null, Token));
await knownHosts.TrustAsync(unknown.Presentation, Token);
return knownHosts;
}
}