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

@@ -17,9 +17,9 @@ public sealed class HostSecretCodecTests
/// <remarks>
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
/// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
/// highest schema version a valid host can.
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
/// pair of pinned paths and a pair of forwards — which are orthogonal to the binding and are what make
/// this host reach the highest schema version a valid host can.
/// </remarks>
[Fact]
public void AFullHost_RoundTrips()
@@ -38,7 +38,8 @@ public sealed class HostSecretCodecTests
credentialId: credentialId,
groupId: Production,
tags: [Pci, EuWest],
pinnedPaths: ["/var/www/app", "/var/log"]);
pinnedPaths: ["/var/www/app", "/var/log"],
portForwards: [Postgres, Socks]);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();