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

@@ -4,6 +4,7 @@ using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
@@ -185,6 +186,73 @@ public sealed class SessionSidebarTests : IAsyncLifetime
});
}
/// <remarks>
/// FORWARDS at its widest: a local forward to a long name, listening, and one that could not start with
/// its reason wrapped under it. The state word sits at the end of the row beside a trimming notation,
/// which is the same shape as the address and its close button above — the word must not be pushed out.
/// </remarks>
[Fact]
public async Task TheForwardsList_FitsWithALongTargetAndAFailureReason()
{
await OnTheSidebarAsync(
(_, window) => LayoutHarness.Unreachable(window).ShouldBeEmpty(),
tab =>
{
var listening = Forward(15432, "customer-a-production-database.eu-west-1.internal", refusal: null);
var refused = Forward(
8080, "web.internal", "The remote would not listen on port 8080. It may be in use there, below 1024, or forbidden by the server's AllowTcpForwarding setting.");
tab.ReplaceForwards([listening, refused]);
// Both starts complete synchronously against these stand-ins, so neither needs awaiting on a
// harness whose body is synchronous.
_ = listening.StartAsync();
_ = refused.StartAsync();
listening.IsListening.ShouldBeTrue();
refused.IsFailed.ShouldBeTrue();
});
}
private static PortForwardViewModel Forward(int port, string target, string? refusal) =>
new(
new PortForwardRule
{
Id = Guid.CreateVersion7(),
Kind = PortForwardKind.Local,
BindPort = port,
TargetHost = target,
TargetPort = port,
},
(spec, _) => refusal is null
? Task.FromResult(Substitute.For<ISshPortForward>())
: Task.FromException<ISshPortForward>(new SshPortForwardException(refusal)),
action => action());
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body, Action<TerminalTabViewModel> arrange) =>
LayoutHarness.OnTheUiThreadAsync(
() =>
{
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
shell.SelectTabCommand.Execute(shell.Tabs[0]);
arrange(shell.Tabs[0]);
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true, ShowsForwards = true };
var window = LayoutHarness.HostAtMinimumSize(
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
body(sidebar, window);
}
finally
{
window.Close();
}
},
Token);
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
LayoutHarness.OnTheUiThreadAsync(
() =>