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

@@ -3832,11 +3832,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
IdentityLabel = e.IdentityLabel,
};
AdoptTab(adopted);
StartForwards(adopted, e);
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
StartForwards(tab, e);
// From this moment, not from when the tab first appeared — connecting is not open, and the session
// shell's elapsed timer is about a shell that is actually running.
@@ -3858,6 +3860,47 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Puts the host's saved forwards on a tab whose session has just opened, and starts every one.
/// </summary>
/// <remarks>
/// <para>
/// All at once and none awaited: each row reports its own outcome, and the terminal is already usable
/// while a remote forward waits for the server to answer. See <see cref="PortForwardViewModel"/> for why
/// one failing stops nothing else.
/// </para>
/// <para>
/// The session id is captured into the start delegate rather than read off the tab when a row is
/// toggled later. A reconnect puts a new id on the same tab, and a row from the old session that reached
/// for the tab's current id would start its listener on a connection it was never part of.
/// </para>
/// <para>
/// ◆ <b>Not on the phone.</b> This view model is shared, and the forwarding would work there unchanged —
/// but that head has no surface listing a tab's forwards, and listeners opened on a phone that never shows
/// them are exactly the invisible state this codebase refuses elsewhere. Its More screen says forwarding
/// is desktop-only for now. A runtime check for the reason <c>RendererNeverStarted</c> gives.
/// </para>
/// </remarks>
private void StartForwards(TerminalTabViewModel tab, TerminalSessionEventArgs e)
{
if (OperatingSystem.IsAndroid())
{
return;
}
var sessionId = e.SessionId;
tab.ReplaceForwards(e.Forwards.Select(rule => new PortForwardViewModel(
rule,
(spec, cancellationToken) => workspace.StartForwardAsync(sessionId, spec, cancellationToken),
post)));
foreach (var forward in tab.Forwards)
{
_ = forward.StartAsync();
}
}
/// <summary>
/// Answers a connection that did not become a session.
/// </summary>
@@ -4658,6 +4701,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
vault.EditSelectedHostCommand.Execute(null);
}
/// <summary>
/// The sidebar's FORWARDS affordance: opens the active tab's host for editing, where forwards are added.
/// </summary>
/// <remarks>
/// The same route <see cref="PinFolderFromSidebar"/> takes, because a forward is saved on the host for
/// the same reason a pin is. A forward added there starts with the next session to the host, not this
/// one — the editor says so — since this session's forwards were started from the host as it was.
/// </remarks>
[RelayCommand]
private void AddForwardFromSidebar() => PinFolderFromSidebar();
/// <summary>
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
/// </summary>