Files
DodoSSH/tests/DodoSSH.Client.Ssh.Tests/PortForwardTests.cs
T
jaap-jan f05deb4fbc
ci / build and test (push) Successful in 3m43s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m6s
ci / desktop nightly (push) Canceled after 21s
Add port forwarding: local, remote and dynamic, per terminal session
A host can now carry port forwards (-L, -R, -D), saved inside its
encrypted payload as RelayTicketRequest.PortForwardId already assumed,
so they sync and merge per forward like tags and pinned paths. The host
schema moves to version 8 only for hosts that carry forwards.

ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports.
Every listener binds loopback and there is no bind-address field: a
forward is something any member of a shared keychain can edit. A remote
forward's target is resolved here, IPv4 first, rather than by SSH.NET's
constructor, which takes the first DNS answer and can send ::1.

Forwards start with every terminal opened to the host and stop with its
connection; a reconnect starts them again. The session sidebar lists
them with state, failure reason and an on/off switch, the status bar
counts the listening ones, and the host editor and detail pane gain a
PORT FORWARDS section. A forward that cannot start fails alone.

The phone does not start forwards yet: it has no surface showing what
is listening, and its More screen now says so. No byte counts, since
SSH.NET keeps none.
2026-09-28 20:47:02 +02:00

253 lines
9.3 KiB
C#

using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
namespace DodoSSH.Client.Ssh.Tests;
/// <summary>
/// The three kinds of forward, each carrying real bytes through the container's sshd.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The target of a local forward is named as the container sees it</b>, for the reason
/// <c>LoopbackProxyTests</c> gives: the remote resolves it, so <c>localhost:2222</c> is the container's own
/// sshd, and the published port this test host uses means nothing in there. What comes back is that sshd's
/// banner, which is bytes nothing but a real forward could have fetched.
/// </para>
/// <para>
/// A remote forward is proved the other way round. A listener on this test host answers with a known line,
/// the remote is asked to listen for it, and a local forward on the same connection reaches that remote
/// port from the inside — so the line has crossed the connection twice, once in each direction.
/// </para>
/// </remarks>
[Collection(SshCollection.Name)]
public sealed class PortForwardTests(SshServerFixture fixture)
{
/// <summary>What the container calls itself. See the class remarks.</summary>
private const string InternalHost = "localhost";
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task ALocalForward_CarriesAConnectionToATargetOnlyTheRemoteCanSee()
{
await using var connection = await ConnectAsync();
await using var forward = await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Local, 0, InternalHost, SshServerFixture.InternalPort),
Token);
forward.IsListening.ShouldBeTrue();
forward.BoundPort.ShouldBeGreaterThan(0, "an ephemeral port is reported back once bound");
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, forward.BoundPort, Token);
(await ReadLineAsync(client.GetStream())).ShouldStartWith("SSH-2.0-");
}
[Fact]
public async Task ADynamicForward_IsASocksProxyWhoseConnectionsLeaveFromTheRemote()
{
await using var connection = await ConnectAsync();
await using var forward = await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Dynamic, 0), Token);
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, forward.BoundPort, Token);
var stream = client.GetStream();
await Socks5ConnectAsync(stream, InternalHost, SshServerFixture.InternalPort);
(await ReadLineAsync(stream)).ShouldStartWith("SSH-2.0-");
}
[Fact]
public async Task ARemoteForward_CarriesAConnectionOnTheRemoteBackToThisMachine()
{
const string Greeting = "hello from the client side";
using var listener = new TcpListener(IPAddress.Loopback, 0);
listener.Start();
var served = ServeOneLineAsync(listener, Greeting);
await using var connection = await ConnectAsync();
var remotePort = RandomNumberGenerator.GetInt32(20000, 60000);
await using var remote = await connection.StartForwardAsync(
new SshPortForwardSpec(
SshPortForwardKind.Remote,
remotePort,
"127.0.0.1",
((IPEndPoint)listener.LocalEndpoint).Port),
Token);
remote.BoundPort.ShouldBe(remotePort);
// Reaches the remote's own loopback, which is where the remote forward is listening.
await using var inward = await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Local, 0, "127.0.0.1", remotePort),
Token);
using var client = new TcpClient();
await client.ConnectAsync(IPAddress.Loopback, inward.BoundPort, Token);
(await ReadLineAsync(client.GetStream())).ShouldBe(Greeting);
await served;
}
[Fact]
public async Task APortAlreadyInUse_IsRefusedWithASentenceNamingIt()
{
using var squatter = new TcpListener(IPAddress.Loopback, 0);
squatter.Start();
var taken = ((IPEndPoint)squatter.LocalEndpoint).Port;
await using var connection = await ConnectAsync();
var refused = await Should.ThrowAsync<SshPortForwardException>(async () =>
await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Local, taken, InternalHost, SshServerFixture.InternalPort),
Token));
refused.Message.ShouldBe($"Port {taken} on this machine is already in use.");
// And the refusal left nothing half-registered on the client: the next forward still starts.
await using var next = await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Local, 0, InternalHost, SshServerFixture.InternalPort),
Token);
next.IsListening.ShouldBeTrue();
}
[Fact]
public async Task DisposingAForward_StopsItsListener()
{
await using var connection = await ConnectAsync();
var forward = await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Local, 0, InternalHost, SshServerFixture.InternalPort),
Token);
var port = forward.BoundPort;
await forward.DisposeAsync();
forward.IsListening.ShouldBeFalse();
await ShouldRefuseAsync(port);
// Idempotent, because the connection closing will try again.
await forward.DisposeAsync();
}
[Fact]
public async Task ClosingTheConnection_StopsEveryForwardOnIt()
{
var connection = await ConnectAsync();
var forward = await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Dynamic, 0), Token);
var port = forward.BoundPort;
await connection.DisposeAsync();
forward.IsListening.ShouldBeFalse();
await ShouldRefuseAsync(port);
// What the workspace does after closing a session, and it must be harmless.
await forward.DisposeAsync();
}
[Fact]
public async Task AnInvalidSpec_IsRefusedBeforeAnythingListens()
{
await using var connection = await ConnectAsync();
await Should.ThrowAsync<ArgumentException>(async () =>
await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Local, 0, TargetHost: null, TargetPort: 0),
Token));
await Should.ThrowAsync<ArgumentException>(async () =>
await connection.StartForwardAsync(
new SshPortForwardSpec(SshPortForwardKind.Remote, 0, "127.0.0.1", 80),
Token));
}
private static async Task ShouldRefuseAsync(int port)
{
using var client = new TcpClient();
await Should.ThrowAsync<SocketException>(async () =>
await client.ConnectAsync(IPAddress.Loopback, port, Token));
}
private static async Task ServeOneLineAsync(TcpListener listener, string line)
{
using var accepted = await listener.AcceptTcpClientAsync(Token);
await accepted.GetStream().WriteAsync(Encoding.ASCII.GetBytes(line + "\n"), Token);
}
/// <summary>
/// The smallest SOCKS5 exchange there is: no authentication, then CONNECT by name.
/// </summary>
private static async Task Socks5ConnectAsync(NetworkStream stream, string host, int port)
{
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
var method = new byte[2];
await stream.ReadExactlyAsync(method, Token);
method.ShouldBe(new byte[] { 5, 0 });
var name = Encoding.ASCII.GetBytes(host);
byte[] request = [5, 1, 0, 3, (byte)name.Length, .. name, (byte)(port >> 8), (byte)port];
await stream.WriteAsync(request, Token);
// An IPv4 bound address, which is what SSH.NET always answers with: ten bytes.
var reply = new byte[10];
await stream.ReadExactlyAsync(reply, Token);
reply[1].ShouldBe((byte)0, "the proxy should report the CONNECT as granted");
}
private static async Task<string> ReadLineAsync(NetworkStream stream)
{
var line = new List<byte>();
var one = new byte[1];
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(Token);
timeout.CancelAfter(TimeSpan.FromSeconds(15));
while (await stream.ReadAsync(one, timeout.Token) == 1 && one[0] != (byte)'\n')
{
line.Add(one[0]);
}
return Encoding.ASCII.GetString([.. line]).TrimEnd('\r');
}
private SshConnectionRequest Request() =>
new(fixture.Host, fixture.Port, SshServerFixture.Username, new SshPasswordCredential(SshServerFixture.Password));
/// <summary>Connects with the container's host key already trusted, since first contact is not the subject.</summary>
private async Task<ISshConnection> ConnectAsync()
{
var knownHosts = new InMemoryKnownHostStore();
var factory = new SshNetConnectionFactory(knownHosts);
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
await factory.ConnectAsync(Request(), progress: null, Token));
await knownHosts.TrustAsync(unknown.Presentation, Token);
return await factory.ConnectAsync(Request(), progress: null, Token);
}
}