Add port forwarding: local, remote and dynamic, per terminal session
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

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.
This commit is contained in:
jaap-jan committed 2026-09-28 20:47:02 +02:00
1 parent c8848ec1e3
commit f05deb4fbc
36 files changed
+2776 -42

No files matched your search

@@ -224,6 +224,17 @@ internal sealed class FakeConnection(SshConnectionRequest request, long bytesPer
return Task.FromResult<ISshShellSession>(Shell);
}
/// <summary>The forwards started on this connection, in order.</summary>
internal List<SshPortForwardSpec> Forwards { get; } = [];
/// <inheritdoc />
public Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
{
Forwards.Add(spec);
return Task.FromResult<ISshPortForward>(new FakePortForward(spec));
}
/// <inheritdoc />
public ValueTask DisposeAsync()
{
@@ -361,3 +372,33 @@ internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
{
public void Report(T value) => report(value);
}
/// <summary>A forward that listens on nothing and says it is listening until disposed.</summary>
internal sealed class FakePortForward(SshPortForwardSpec spec) : ISshPortForward
{
/// <inheritdoc />
public event EventHandler<SshPortForwardErrorEventArgs>? ErrorOccurred
{
add { }
remove { }
}
/// <inheritdoc />
public SshPortForwardSpec Spec { get; } = spec;
/// <inheritdoc />
public int BoundPort => Spec.BindPort;
/// <inheritdoc />
public bool IsListening { get; private set; } = true;
/// <inheritdoc />
public string? LastError => null;
/// <inheritdoc />
public ValueTask DisposeAsync()
{
IsListening = false;
return ValueTask.CompletedTask;
}
}
@@ -599,6 +599,53 @@ public sealed class TerminalWorkspaceTests
await WaitUntilAsync(() => Volatile.Read(ref reattachedCount) > 0);
}
// ---- Port forwards ----
[Fact]
public async Task AForward_RunsOverTheConnectionOfTheSessionItNames()
{
var connections = new FakeConnectionFactory(blockShellReads: true);
await using var workspace = CreateWorkspace(connections);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
var second = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
var spec = new SshPortForwardSpec(SshPortForwardKind.Local, 5432, "db.internal", 5432);
await using var forward = await workspace.StartForwardAsync(
second, spec, TestContext.Current.CancellationToken);
forward.Spec.ShouldBe(spec);
connections.Connections[0].Forwards.ShouldBeEmpty();
connections.Connections[1].Forwards.ShouldBe([spec]);
}
[Fact]
public async Task AForwardOnASessionThatIsNotRunning_IsRefused()
{
var connections = new FakeConnectionFactory(bytesPerShell: 0);
await using var workspace = CreateWorkspace(connections);
var ended = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
await WaitUntilAsync(() => !workspace.IsSessionLive(ended));
var spec = new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080);
await Should.ThrowAsync<InvalidOperationException>(async () =>
await workspace.StartForwardAsync(ended, spec, TestContext.Current.CancellationToken));
await Should.ThrowAsync<InvalidOperationException>(async () =>
await workspace.StartForwardAsync(999, spec, TestContext.Current.CancellationToken));
connections.Connections[0].Forwards.ShouldBeEmpty();
}
// ---- Helpers ----
/// <remarks>