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

@@ -187,6 +187,13 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
vault.EditorNewPin = "/var/www/a-fairly-long-application-directory-name";
vault.AddEditorPinCommand.Execute(null);
// And a forward, for the same reason: PORT FORWARDS draws its rows in the same mono face, and its
// add row is three controls abreast in a column 304 pixels wide.
vault.EditorNewForwardBindPort = "15432";
vault.EditorNewForwardTarget = "customer-a-production-database.internal:5432";
vault.AddEditorForwardCommand.Execute(null);
vault.EditorPortForwards.ShouldHaveSingleItem(vault.Status);
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
}
@@ -1013,9 +1020,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}
/// <remarks>
/// The FIELDS column is the one that grows: it is a list of names, and a host has thirteen of them —
/// eleven until inheritance added "Password prompt" and tags added "Tags". Measured with an edit that
/// touched several, because one field name fits anywhere. The count is stated rather than derived, so
/// The FIELDS column is the one that grows: it is a list of names, and a host has fourteen of them —
/// eleven until inheritance added "Password prompt", tags added "Tags" and forwards added "Port
/// forwards". Measured with an edit that touched several, because one field name fits anywhere. The
/// count is stated rather than derived, so
/// it has to be recounted against <c>HostKind.Changes</c> whenever a field is added; the literal in
/// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
/// </remarks>
@@ -2244,7 +2252,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
ItemId = Guid.CreateVersion7(),
ItemLabel = "customer-a-production-database",
Operation = ActivityOperation.Updated,
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags",
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags, Port forwards",
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
DeviceName = "jaap-jan-workstation",
},