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