Public Access
Add port forwarding: local, remote and dynamic, per terminal session
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:
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",
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
Reference in new issue
Block a user