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

@@ -189,13 +189,14 @@
◆ Port forwarding, stated rather than drawn.
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
rules with toggles and byte counters. Nothing in this application forwards anything — ISshConnection
offers OpenShellAsync and nothing else — so every switch on that screen would be a control with no
effect and every counter a number with no source.
rules with toggles and byte counters. Forwarding is built — a host's forwards start with every
desktop terminal to it, listed in the session sidebar — but not started here:
MainWindowViewModel.StartForwards skips them on Android, because this head has no surface that
lists what is listening, and a phone opening listeners it never shows is worse than one opening
none. The byte counters are not built anywhere; SSH.NET keeps no count to draw.
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
a destination that is quietly absent reads as a product that never intended it, and this one is
intended. SyncEntityType.PortForward = 9 is reserved on the wire and unused. See
a destination that is quietly absent reads as a product that never intended it. See
docs/design-import-gaps.md.
-->
<Border Classes="card" Margin="6,16,6,0">
@@ -206,11 +207,11 @@
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
<Border Classes="tag outline" VerticalAlignment="Center">
<TextBlock Text="NOT BUILT" />
<TextBlock Text="DESKTOP ONLY" />
</Border>
</StackPanel>
<TextBlock Classes="body"
Text="A connection here opens one channel — a shell — and nothing else, so there is no tunnel for a rule to run through. The wire protocol reserves a slot for forwarding rules and no client writes one. Nothing on this screen is hidden by a setting; the feature is absent." />
Text="Forwards saved on a host start with every terminal the desktop app opens to it. This phone does not start them, because it has nowhere yet to show you what is listening. They are kept on the host all the same, and nothing here changes them." />
</StackPanel>
</Border>
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:dom="using:DodoSSH.Client.Domain"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostDrawer"
x:DataType="vm:VaultViewModel">
@@ -385,6 +386,33 @@
</StackPanel>
</Border>
<!--
============ FORWARDS (read-only) ============
QUICK ACCESS's shape, and hidden when empty for the same reason: a card with nothing under it
answers a question nobody asked here.
-->
<Border Classes="section" IsVisible="{Binding SelectedHost.HasForwards}">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="Port forwards" Margin="0,0,0,2" />
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="13"
Foreground="{StaticResource TextFaint}" Width="16" VerticalAlignment="Top"
Margin="0,3,0,0" Text="&#xE8D4;" />
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.Host.PortForwards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dom:PortForwardRule">
<TextBlock Classes="mono" Text="{Binding Notation}" FontSize="12"
TextTrimming="CharacterEllipsis" Margin="0,0,0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Button>
</StackPanel>
</Border>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
@@ -740,6 +768,84 @@
</StackPanel>
</Border>
<!--
============ PORT FORWARDS ============
Staged on VaultViewModel.EditorPortForwards the way QUICK ACCESS stages its pins, and saved
with the host. Each row is drawn in OpenSSH's own notation, because that is what anybody who has
used one reads fastest; the add row spells out which end the listening port is on instead,
because -L and -R are the two flags people get backwards.
No bind-address field, and that is not an omission: every listener binds loopback. A forward is
a field every member of a shared keychain can fill in, and one bound to 0.0.0.0 would hand the
remote's network to anybody who can reach this machine. See SshPortForwardSpec.
-->
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Classes="sectionlabel" Text="PORT FORWARDS" />
<ItemsControl ItemsSource="{Binding EditorPortForwards}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dom:PortForwardRule">
<Border Height="33" CornerRadius="9" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="8,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="14"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="&#xE8D4;" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Notation}" FontSize="12.5"
Margin="8,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Notation}" />
<Button Grid.Column="2" Classes="flat paneicon" Content="✕" Width="22" Height="22"
FontSize="11"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorForwardCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Removes this forward" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ComboBox SelectedIndex="{Binding EditorNewForwardKind}" HorizontalAlignment="Stretch"
Height="36">
<ComboBoxItem Content="Local -L" />
<ComboBoxItem Content="Remote -R" />
<ComboBoxItem Content="Dynamic -D (SOCKS)" />
</ComboBox>
<!-- Which end each port is on, for the kind chosen: the thing -L and -R get backwards. -->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="{Binding EditorNewForwardKindHint}" />
<Grid ColumnDefinitions="104,6,*,6,Auto">
<TextBox Grid.Column="0" FontFamily="{StaticResource MonoFont}"
Text="{Binding EditorNewForwardBindPort}"
PlaceholderText="{Binding EditorNewForwardBindHint}" Height="36"
ToolTip.Tip="{Binding EditorNewForwardBindHint}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorForwardCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBox Grid.Column="2" FontFamily="{StaticResource MonoFont}"
Text="{Binding EditorNewForwardTarget}"
IsVisible="{Binding EditorNewForwardHasTarget}"
PlaceholderText="host:port" Height="36">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorForwardCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="4" Classes="ghost" Content="+" Width="36" Height="36" FontSize="15"
HorizontalContentAlignment="Center"
Command="{Binding AddEditorForwardCommand}" ToolTip.Tip="Adds this forward" />
</Grid>
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="Forwards start with every terminal opened to this host and stop when it closes. Everything listens on localhost only. A change here applies from the next connection." />
</StackPanel>
</Border>
</StackPanel>
<!-- ============ THE GROUP EDITOR ============ -->
@@ -332,7 +332,7 @@
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
"Auto" column it sits in, so the pane column takes the width back.
-->
<views:SessionSidebar Grid.Column="1" ShowsSnips="True"
<views:SessionSidebar Grid.Column="1" ShowsSnips="True" ShowsForwards="True"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid>
</Border>
@@ -39,6 +39,22 @@
is showing without knowing anything about the state — and the pane beside it grows into what is freed.
-->
<!--
A forward's state word: green only while it is listening, which is the one status Live is for, and the
danger text colour for a forward that could not start. OFF and STARTING stay faint.
-->
<UserControl.Styles>
<Style Selector="TextBlock.fwdstate">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.fwdstate.live">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.fwdstate.failed">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
</UserControl.Styles>
<Panel>
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
@@ -142,6 +158,64 @@
</StackPanel>
</Button>
<!-- ============ FORWARDS (the terminal surface only) ============ -->
<!--
The selected tab's forwards: the host's saved ones, as they run over this session. Each row is
its own switch, and a failed one says why under its notation — a port already taken, most often
by a second tab to the same host running the same forwards. Nothing here edits a forward; the add
row goes to the host's editor, where they are saved, as Pin folder does for QUICK ACCESS.
No byte counts, which the design draws. SSH.NET keeps no count of what a forward carries, so a
number here would be invented.
-->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsForwards}">
<TextBlock Classes="label" Text="FORWARDS" FontSize="10" Margin="8,0" />
<ItemsControl ItemsSource="{Binding SelectedTab.Forwards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PortForwardViewModel">
<!--
The reason sits under the row rather than inside it: a sidebar row is a fixed 33 pixels, and
a refusal is a sentence. Indented to the notation, so it reads as belonging to the row.
-->
<StackPanel>
<Button Classes="sidebarrow" Command="{Binding ToggleCommand}"
ToolTip.Tip="{Binding Route}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="&#xE8D4;"
FontFamily="{StaticResource IconFont}" FontSize="15"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
Margin="0,0,10,0" />
<TextBlock Grid.Column="1" FontFamily="{StaticResource MonoFont}" FontSize="12.5"
Text="{Binding Notation}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="fwdstate" FontFamily="Montserrat"
FontWeight="SemiBold" FontSize="9" LetterSpacing="1"
VerticalAlignment="Center" Margin="8,0,0,0"
Text="{Binding StateText}"
Classes.live="{Binding IsListening}" Classes.failed="{Binding IsFailed}" />
</Grid>
</Button>
<TextBlock FontSize="11" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
Margin="37,0,12,4" Text="{Binding Detail}"
IsVisible="{Binding Detail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddForwardFromSidebarCommand}"
ToolTip.Tip="Opens the active tab's host for editing, where its forwards are saved. A new forward starts with the next connection.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Add forward" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- ============ SNIPS (the terminal surface only) ============ -->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
@@ -19,6 +19,10 @@ internal sealed partial class SessionSidebar : UserControl
internal static readonly StyledProperty<bool> ShowsSnipsProperty =
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsSnips));
/// <summary>Whether the FORWARDS section draws. The terminal's own, for the reason SNIPS is.</summary>
internal static readonly StyledProperty<bool> ShowsForwardsProperty =
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsForwards));
public SessionSidebar() => InitializeComponent();
internal bool ShowsSnips
@@ -26,4 +30,10 @@ internal sealed partial class SessionSidebar : UserControl
get => GetValue(ShowsSnipsProperty);
set => SetValue(ShowsSnipsProperty, value);
}
internal bool ShowsForwards
{
get => GetValue(ShowsForwardsProperty);
set => SetValue(ShowsForwardsProperty, value);
}
}
@@ -76,6 +76,13 @@
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<!--
How many of the selected tab's forwards are listening, and nothing when none are. The terminal's
own, for the reason UTF-8 is: an SFTP connection runs no forwards.
-->
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SelectedTab.ForwardsSummary}"
IsVisible="{Binding #Root.ShowsEncoding}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
IsVisible="{Binding SessionElapsedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
+3 -2
View File
@@ -9,8 +9,9 @@
The design puts five things here: the active host and the bastion it is reached through, the open port
forwards, the SFTP transfer count, an auto-lock countdown, and the last sync time. Four of those five
have nothing behind them — there is no jump-host routing, no port forwarding, no file transfer and no
idle auto-lock — so what is left is the active session, the vault's last message, and the shortcut hint.
had nothing behind them when this was written — there was no jump-host routing, no port forwarding, no
file transfer and no idle auto-lock — so what is left is the active session, the vault's last message,
and the shortcut hint. Forwards have since been built, and are counted on SessionStatusBar instead.
The last message is the load-bearing one, and it is why this bar is worth having at all: the vault's
status line used to be a cramped label in the account bar, and everything the application has to say
+40 -1
View File
@@ -45,6 +45,13 @@ public sealed record HostSecret : IVaultSecret
/// </remarks>
public const int MaxPinnedPaths = 32;
/// <summary>The most port forwards a host may carry.</summary>
/// <remarks>
/// Each one is a listener opened with every terminal to the host, so the bound is on what one connect
/// may open as much as on the payload.
/// </remarks>
public const int MaxPortForwards = 16;
/// <summary>Display name. The only name this host has anywhere.</summary>
public required string Label { get; init; }
@@ -223,6 +230,15 @@ public sealed record HostSecret : IVaultSecret
/// </remarks>
public PinnedPathList PinnedPaths { get; init; } = PinnedPathList.Empty;
/// <summary>
/// The port forwards started with every terminal opened to this host.
/// </summary>
/// <remarks>
/// Here rather than in an item of their own; see <see cref="PortForwardRule"/>. Merged per forward, so two
/// machines each adding one both keep theirs, and two editing the same one is a recorded conflict.
/// </remarks>
public PortForwardList PortForwards { get; init; } = PortForwardList.Empty;
/// <summary>
/// The group this host is filed under, or null for none.
/// </summary>
@@ -314,7 +330,30 @@ public sealed record HostSecret : IVaultSecret
return false;
}
return PinnedPathsAreStorable(out reason) && ReferencesAreStorable(out reason);
return PinnedPathsAreStorable(out reason)
&& PortForwardsAreStorable(out reason)
&& ReferencesAreStorable(out reason);
}
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
private bool PortForwardsAreStorable([NotNullWhen(false)] out string? reason)
{
if (PortForwards.Count > MaxPortForwards)
{
reason = $"A host cannot carry more than {MaxPortForwards} port forwards.";
return false;
}
foreach (var forward in PortForwards)
{
if (!forward.TryValidate(out reason))
{
return false;
}
}
reason = null;
return true;
}
/// <summary>
+94 -1
View File
@@ -112,12 +112,19 @@ public static class HostSecretCodec
/// </remarks>
public const int PinnedPathsSchemaVersion = 7;
/// <summary>The version that introduced <see cref="HostSecret.PortForwards"/>.</summary>
/// <remarks>
/// Its own version for the reason <see cref="PinnedPathsSchemaVersion"/> gives: a host can carry
/// forwards without pinning anything, and should stay editable on a build that knows 7 until it does.
/// </remarks>
public const int PortForwardsSchemaVersion = 8;
/// <summary>The highest schema version this build can write.</summary>
/// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
/// maximum. A new field added below this line has to be named here too.
/// </remarks>
public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;
public const int CurrentSchemaVersion = PortForwardsSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -163,6 +170,11 @@ public static class HostSecretCodec
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
// as it did before this field existed.
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
// Last, and null when empty, for the reason both fields above give.
PortForwards = host.PortForwards.Count == 0
? null
: [.. host.PortForwards.Select(PortForwardDocument.From)],
};
return JsonSerializer.SerializeToUtf8Bytes(
@@ -238,6 +250,11 @@ public static class HostSecretCodec
version = Math.Max(version, PinnedPathsSchemaVersion);
}
if (host.PortForwards.Count > 0)
{
version = Math.Max(version, PortForwardsSchemaVersion);
}
return version;
}
@@ -295,6 +312,11 @@ public static class HostSecretCodec
return false;
}
if (!TryBuildForwards(parsed.PortForwards, out var forwards))
{
return false;
}
var candidate = new HostSecret
{
Label = parsed.Label ?? string.Empty,
@@ -316,6 +338,8 @@ public static class HostSecretCodec
TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
PortForwards = forwards,
};
if (!candidate.TryValidate(out _))
@@ -326,6 +350,45 @@ public static class HostSecretCodec
host = candidate;
return true;
}
/// <remarks>
/// A missing id, kind or port is a refusal rather than a default. Each would be invented here — a fresh
/// id the other machines do not have, a <c>Local</c> nobody chose — and an invented forward is one the
/// next save writes back as though somebody had asked for it.
/// </remarks>
private static bool TryBuildForwards(
PortForwardDocument?[]? parsed,
[NotNullWhen(true)] out PortForwardList? forwards)
{
forwards = PortForwardList.Empty;
if (parsed is null)
{
return true;
}
var rules = new List<PortForwardRule>(parsed.Length);
foreach (var entry in parsed)
{
if (entry?.Id is not { } id || entry.Kind is not { } kind || entry.BindPort is not { } bindPort)
{
return false;
}
rules.Add(new PortForwardRule
{
Id = id,
Kind = (PortForwardKind)kind,
BindPort = bindPort,
TargetHost = entry.TargetHost,
TargetPort = entry.TargetPort,
});
}
forwards = PortForwardList.Create(rules);
return true;
}
}
/// <summary>
@@ -396,6 +459,36 @@ internal sealed class HostPayloadDocument
/// newer of the two and follows the same rule.
/// </remarks>
public string[]? PinnedPaths { get; set; }
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
public PortForwardDocument?[]? PortForwards { get; set; }
}
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
/// <remarks>
/// The kind is written as its number rather than its name, so renaming a member of
/// <see cref="PortForwardKind"/> can never change what is on the wire.
/// </remarks>
internal sealed class PortForwardDocument
{
public Guid? Id { get; set; }
public int? Kind { get; set; }
public int? BindPort { get; set; }
public string? TargetHost { get; set; }
public int? TargetPort { get; set; }
public static PortForwardDocument From(PortForwardRule rule) => new()
{
Id = rule.Id,
Kind = (int)rule.Kind,
BindPort = rule.BindPort,
TargetHost = rule.TargetHost,
TargetPort = rule.TargetPort,
};
}
[JsonSourceGenerationOptions(
@@ -99,6 +99,8 @@ public static class HostSecretMerge
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
PinnedPaths = MergePinnedPaths(
ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
PortForwards = MergePortForwards(
ancestor.PortForwards, local.PortForwards, remote.PortForwards, conflicts),
RelayEnabled = Field(
nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled,
@@ -342,6 +344,46 @@ public static class HostSecretMerge
return PinnedPathList.Create(merge.Merged.Keys);
}
/// <summary>
/// Merges the forwards per forward, so two people each adding one both keep theirs.
/// </summary>
/// <remarks>
/// <para>
/// Keyed by id and ordered the way <see cref="MergePinnedPaths"/> is: existing forwards, then this
/// machine's additions, then the server's.
/// </para>
/// <para>
/// <b>Unlike a tag or a pinned path, a conflict is reachable here.</b> The value under a key is the whole
/// rule, so two machines changing one forward differently is a genuine clash. The server's version wins,
/// as every scalar's does, and the other is recorded in OpenSSH's notation so the user can read what was
/// lost. An edit on one side beats a removal on the other, which is the map merge's standing rule.
/// </para>
/// </remarks>
private static PortForwardList MergePortForwards(
PortForwardList ancestor,
PortForwardList local,
PortForwardList remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToIdMap(),
local.ToIdMap(),
remote.ToIdMap(),
EqualityComparer<Guid>.Default);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.PortForwards)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept?.Notation ?? "removed",
conflict.Discarded?.Notation ?? "removed",
conflict.DiscardedWasRemoval));
}
return PortForwardList.Create(merge.Merged.Values);
}
private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
}
@@ -0,0 +1,140 @@
using System.Collections;
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Domain;
/// <summary>
/// The port forwards saved on a host, in the order the user added them.
/// </summary>
/// <remarks>
/// <para>
/// A dedicated type rather than a list, for the reason <see cref="PinnedPathList"/> gives: a plain list on a
/// record compares by reference, so every host would read as changed on every sync pass.
/// </para>
/// <para>
/// <b>Ordered and unique by id.</b> Order is how the session sidebar lists them. Uniqueness is by
/// <see cref="PortForwardRule.Id"/> rather than by value, keeping the first occurrence, because the id is
/// what the merge keys on — two entries with one id would be one forward the merge could not tell apart.
/// </para>
/// <para>
/// Invalid rules are not rejected at construction, for the reason every collection in this assembly gives:
/// they arrive from another client's payload, and a throw here would fail the whole sync pass.
/// <see cref="HostSecret.TryValidate"/> catches them.
/// </para>
/// </remarks>
public sealed class PortForwardList : IReadOnlyList<PortForwardRule>, IEquatable<PortForwardList>
{
private readonly PortForwardRule[] rules;
private readonly int hash;
private PortForwardList(PortForwardRule[] rules)
{
this.rules = rules;
hash = ComputeHash(rules);
}
/// <summary>No forwards.</summary>
public static PortForwardList Empty { get; } = new([]);
/// <inheritdoc />
public int Count => rules.Length;
/// <inheritdoc />
public PortForwardRule this[int index] => rules[index];
/// <summary>Copies a sequence of rules, keeping order and the first rule with any given id.</summary>
public static PortForwardList Create(IEnumerable<PortForwardRule> rules)
{
ArgumentNullException.ThrowIfNull(rules);
var kept = new List<PortForwardRule>();
var seen = new HashSet<Guid>();
foreach (var rule in rules)
{
if (rule is null)
{
throw new ArgumentException("A forward list cannot contain a null rule.", nameof(rules));
}
if (seen.Add(rule.Id))
{
kept.Add(rule);
}
}
return kept.Count == 0 ? Empty : new PortForwardList([.. kept]);
}
/// <summary>
/// The list as a map from id to rule, in list order, which is the shape <see cref="ThreeWayMerge.Map"/>
/// takes.
/// </summary>
/// <remarks>
/// Built in list order for the reason <see cref="PinnedPathList.ToPathMap"/> is: the merge unions keys
/// ancestor first, then local, then remote, so an ordered map is what keeps "existing forwards, then
/// additions" without the merge ordering anything itself. Unlike a pinned path, a value here can change
/// under its key, so a conflict is reachable: both machines editing the same forward differently.
/// </remarks>
public IReadOnlyDictionary<Guid, PortForwardRule> ToIdMap()
{
var map = new Dictionary<Guid, PortForwardRule>(rules.Length);
foreach (var rule in rules)
{
map[rule.Id] = rule;
}
return map;
}
/// <inheritdoc />
public bool Equals(PortForwardList? other)
{
if (ReferenceEquals(this, other))
{
return true;
}
return other is not null
&& other.hash == hash
&& rules.AsSpan().SequenceEqual(other.rules);
}
/// <inheritdoc />
public override bool Equals(object? obj) => Equals(obj as PortForwardList);
/// <inheritdoc />
public override int GetHashCode() => hash;
/// <inheritdoc />
public IEnumerator<PortForwardRule> GetEnumerator() => ((IEnumerable<PortForwardRule>)rules).GetEnumerator();
/// <inheritdoc />
IEnumerator IEnumerable.GetEnumerator() => rules.GetEnumerator();
/// <summary>Contents equality, tolerating nulls on either side.</summary>
[SuppressMessage(
"Usage",
"CA2225:Operator overloads have named alternates",
Justification = "Equals(PortForwardList) is the named alternate.")]
public static bool operator ==(PortForwardList? left, PortForwardList? right) =>
left is null ? right is null : left.Equals(right);
/// <summary>Contents inequality.</summary>
public static bool operator !=(PortForwardList? left, PortForwardList? right) => !(left == right);
private static int ComputeHash(PortForwardRule[] rules)
{
// Order-sensitive, because order is kept.
var accumulator = new HashCode();
accumulator.Add(rules.Length);
foreach (var rule in rules)
{
accumulator.Add(rule);
}
return accumulator.ToHashCode();
}
}
@@ -0,0 +1,166 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace DodoSSH.Client.Domain;
/// <summary>Which way a saved forward carries connections.</summary>
/// <remarks>
/// Stored as its number, so the values are part of the payload format and must never be renumbered. The
/// SSH layer has its own enum with the same three members; the connect path maps one to the other, which
/// keeps this assembly free of any reference to it.
/// </remarks>
public enum PortForwardKind
{
/// <summary>A port on this machine, carried to a target the remote can reach. <c>ssh -L</c>.</summary>
Local = 0,
/// <summary>A port on the remote, carried back to a target this machine can reach. <c>ssh -R</c>.</summary>
Remote = 1,
/// <summary>A SOCKS proxy on this machine, whose connections leave from the remote. <c>ssh -D</c>.</summary>
Dynamic = 2,
}
/// <summary>
/// A port forward saved on a host, started with every terminal opened to it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Part of the host's encrypted payload rather than an item of its own</b>, which is what
/// <c>RelayTicketRequest.PortForwardId</c> has said since the contract was frozen. A forward means nothing
/// without the host it runs over, it is edited in the host's own editor, and a separate item kind would be a
/// table, a server kind, a cipher and a repository to buy one thing the host's per-id merge already gives:
/// two machines each adding a forward both keep theirs. <c>SyncEntityType.PortForward</c> stays reserved,
/// the way <c>HostTag</c> does for the same reason.
/// </para>
/// <para>
/// <b>There is no bind address.</b> Every listener binds loopback — see <c>SshPortForwardSpec</c> for why a
/// field another member of the keychain can fill in must not be able to say otherwise.
/// </para>
/// <para>
/// <see cref="Id"/> is what the merge keys on, so editing a forward's port on one machine and its target on
/// another is one forward changed twice — a conflict the merge records — rather than two forwards.
/// </para>
/// </remarks>
public sealed record PortForwardRule
{
/// <summary>The longest a target host may be, which is the longest a DNS name can be.</summary>
public const int MaxTargetHostLength = 253;
/// <summary>Identifies this forward within its host, for the merge.</summary>
public required Guid Id { get; init; }
/// <summary>Which way it carries connections.</summary>
public required PortForwardKind Kind { get; init; }
/// <summary>
/// The port to listen on: on this machine for a local or dynamic forward, on the remote for a remote one.
/// </summary>
public required int BindPort { get; init; }
/// <summary>Where each connection goes, or null for a dynamic forward.</summary>
/// <remarks>
/// Resolved by the remote for a local forward — so it may name a machine only the remote can see — and
/// by this machine for a remote one.
/// </remarks>
public string? TargetHost { get; init; }
/// <summary>The port on <see cref="TargetHost"/>, or null for a dynamic forward.</summary>
public int? TargetPort { get; init; }
/// <summary>
/// The forward in OpenSSH's own notation, which is what anybody who has used one will read fastest.
/// </summary>
/// <remarks>
/// <c>-L 5432:db.internal:5432</c>, <c>-R 8080:localhost:3000</c>, <c>-D 1080</c>. No bind address, because
/// there is never one to show.
/// </remarks>
public string Notation => Kind switch
{
PortForwardKind.Local => $"-L {Port(BindPort)}:{TargetHost}:{Port(TargetPort)}",
PortForwardKind.Remote => $"-R {Port(BindPort)}:{TargetHost}:{Port(TargetPort)}",
_ => $"-D {Port(BindPort)}",
};
/// <summary>Checks the fields that must hold before this can be stored.</summary>
/// <remarks>
/// Deliberately silent about two forwards on one port. Two machines each adding one is exactly what the
/// merge produces, and a host that failed validation after a merge could not be written back at all; the
/// second listener fails when it starts instead, and says so. The editor refuses the duplicate up front.
/// </remarks>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (Id == Guid.Empty)
{
reason = "A port forward cannot have an empty id.";
return false;
}
if (!Enum.IsDefined(Kind))
{
reason = $"'{(int)Kind}' is not a kind of port forward.";
return false;
}
if (BindPort is < 1 or > 65535)
{
reason = $"A forward's listening port must be between 1 and 65535, not {BindPort}.";
return false;
}
if (Kind is PortForwardKind.Dynamic)
{
if (TargetHost is not null || TargetPort is not null)
{
reason = "A dynamic forward has no target: each connection through it names its own.";
return false;
}
reason = null;
return true;
}
return TargetIsStorable(out reason);
}
private static string Port(int? port) => port?.ToString(CultureInfo.InvariantCulture) ?? "?";
/// <remarks>
/// Whitespace and control characters are refused as well as blanks: neither is part of any host name,
/// both are what a corrupted payload looks like, and this string is drawn on screen as it stands.
/// </remarks>
private bool TargetIsStorable([NotNullWhen(false)] out string? reason)
{
if (string.IsNullOrWhiteSpace(TargetHost))
{
reason = "A forward needs a host to forward to.";
return false;
}
if (TargetHost.Length > MaxTargetHostLength)
{
reason = $"A forward's target cannot be longer than {MaxTargetHostLength} characters.";
return false;
}
foreach (var ch in TargetHost)
{
if (char.IsControl(ch) || char.IsWhiteSpace(ch))
{
reason = "A forward's target cannot contain spaces or control characters.";
return false;
}
}
if (TargetPort is not (>= 1 and <= 65535))
{
reason = TargetPort is { } port
? $"A forward's target port must be between 1 and 65535, not {port}."
: "A forward needs a port to forward to.";
return false;
}
reason = null;
return true;
}
}
@@ -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>
@@ -0,0 +1,249 @@
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Where one of a tab's forwards has got to.</summary>
internal enum PortForwardState
{
/// <summary>Not listening: switched off, or its session is over.</summary>
Stopped = 0,
/// <summary>Asked for and not answered yet. A remote forward waits on the server.</summary>
Starting = 1,
/// <summary>Listening.</summary>
Listening = 2,
/// <summary>Could not be started; <see cref="PortForwardViewModel.Detail"/> says why.</summary>
Failed = 3,
}
/// <summary>
/// One of a host's saved forwards, as it runs on one terminal tab.
/// </summary>
/// <remarks>
/// <para>
/// Built fresh for each session the tab holds, and started with it: the rule is the host's, the listener is
/// this session's. A reconnect is a new session, so it gets new rows — see
/// <see cref="TerminalTabViewModel.ReplaceForwards"/> — rather than trying to carry a listener across a
/// connection that no longer exists.
/// </para>
/// <para>
/// <b>One failing does not stop the others, or the terminal.</b> The commonest failure is a port already in
/// use — very often by a second tab to the same host, which is running the same forwards — and a shell that
/// would not open because of it would be the forward taking the session hostage. The row says why instead.
/// </para>
/// </remarks>
internal sealed partial class PortForwardViewModel : ObservableObject
{
private readonly Func<SshPortForwardSpec, CancellationToken, Task<ISshPortForward>> start;
private readonly Action<Action> post;
private ISshPortForward? forward;
private bool sessionOver;
/// <summary>The detail line: why it failed, or what last went wrong through it.</summary>
[ObservableProperty]
private string? detail;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsListening))]
[NotifyPropertyChangedFor(nameof(IsFailed))]
[NotifyPropertyChangedFor(nameof(CanToggle))]
[NotifyPropertyChangedFor(nameof(StateText))]
[NotifyCanExecuteChangedFor(nameof(ToggleCommand))]
private PortForwardState state;
/// <param name="rule">The host's saved forward.</param>
/// <param name="start">Starts a forward on this tab's session; see <c>TerminalWorkspace.StartForwardAsync</c>.</param>
/// <param name="post">Runs an action on the UI thread, for reports SSH.NET raises on its own.</param>
internal PortForwardViewModel(
PortForwardRule rule,
Func<SshPortForwardSpec, CancellationToken, Task<ISshPortForward>> start,
Action<Action> post)
{
Rule = rule;
this.start = start;
this.post = post;
}
/// <summary>The saved forward this row runs.</summary>
internal PortForwardRule Rule { get; }
/// <summary>The forward in OpenSSH's notation, e.g. <c>-L 5432:db.internal:5432</c>.</summary>
internal string Notation => Rule.Notation;
/// <summary>What the forward connects to what, in words rather than flags.</summary>
/// <remarks>
/// Which end each port is on is the thing people get backwards about <c>-L</c> and <c>-R</c>, so it is
/// written out: "this machine" and "the remote" rather than an arrow whose direction has to be learned.
/// </remarks>
internal string Route => Rule.Kind switch
{
PortForwardKind.Local =>
$"localhost:{Port(Rule.BindPort)} here → {Rule.TargetHost}:{Port(Rule.TargetPort)} from the remote",
PortForwardKind.Remote =>
$"localhost:{Port(Rule.BindPort)} on the remote → {Rule.TargetHost}:{Port(Rule.TargetPort)} from here",
_ => $"SOCKS proxy on localhost:{Port(Rule.BindPort)}, out through the remote",
};
/// <summary>Whether it is listening now.</summary>
internal bool IsListening => State is PortForwardState.Listening;
/// <summary>Whether it could not be started.</summary>
internal bool IsFailed => State is PortForwardState.Failed;
/// <summary>
/// Whether the switch does anything: not while a start is in flight, and never once the session is over.
/// </summary>
internal bool CanToggle => !sessionOver && State is not PortForwardState.Starting;
/// <summary>The state in the status bar's own lettering.</summary>
internal string StateText => State switch
{
PortForwardState.Starting => "STARTING",
PortForwardState.Listening => "LISTENING",
PortForwardState.Failed => "FAILED",
_ => "OFF",
};
/// <summary>Starts the forward, unless it is already running or its session has ended.</summary>
/// <remarks>
/// Never throws. It is started without anyone waiting on it — every forward on a tab at once, as the
/// tab opens — so a failure has nowhere to go but this row.
/// </remarks>
internal async Task StartAsync()
{
if (sessionOver || State is PortForwardState.Starting or PortForwardState.Listening)
{
return;
}
State = PortForwardState.Starting;
Detail = null;
ISshPortForward started;
try
{
started = await start(ToSpec(Rule), CancellationToken.None).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Unless the session ended while this was in flight, in which case that is the true story and
// SessionEnded has already told it.
if (!sessionOver)
{
Detail = exception.Message;
State = PortForwardState.Failed;
}
return;
}
if (sessionOver)
{
// The session ended while the server was answering. Its connection has gone and taken the
// listener with it; this only lets go of the handle.
await started.DisposeAsync().ConfigureAwait(true);
return;
}
forward = started;
started.ErrorOccurred += OnForwardError;
State = PortForwardState.Listening;
}
/// <summary>Stops the forward and leaves the row switched off.</summary>
internal async Task StopAsync()
{
await ReleaseAsync().ConfigureAwait(true);
if (!sessionOver)
{
State = PortForwardState.Stopped;
Detail = null;
}
}
/// <summary>
/// Records that the session this forward ran over has ended, which stopped the forward with it.
/// </summary>
/// <remarks>
/// A failed row keeps its reason: that the session later ended does not make it any less true that the
/// port was taken. Anything else is off, and says it went with the session rather than being switched
/// off, which is the difference between "I did that" and "that happened".
/// </remarks>
internal void SessionEnded()
{
if (sessionOver)
{
return;
}
sessionOver = true;
_ = ReleaseAsync();
if (State is not PortForwardState.Failed)
{
State = PortForwardState.Stopped;
Detail = "Stopped with the session.";
}
OnPropertyChanged(nameof(CanToggle));
ToggleCommand.NotifyCanExecuteChanged();
}
/// <summary>The SSH layer's spec for a saved rule; the two enums share their numbering on purpose.</summary>
internal static SshPortForwardSpec ToSpec(PortForwardRule rule) => rule.Kind switch
{
PortForwardKind.Local => new SshPortForwardSpec(
SshPortForwardKind.Local, rule.BindPort, rule.TargetHost, rule.TargetPort ?? 0),
PortForwardKind.Remote => new SshPortForwardSpec(
SshPortForwardKind.Remote, rule.BindPort, rule.TargetHost, rule.TargetPort ?? 0),
_ => new SshPortForwardSpec(SshPortForwardKind.Dynamic, rule.BindPort),
};
/// <remarks>A failed row's switch is a retry: the port may have been freed since.</remarks>
[RelayCommand(CanExecute = nameof(CanToggle))]
private Task ToggleAsync() => IsListening ? StopAsync() : StartAsync();
private async Task ReleaseAsync()
{
if (forward is not { } running)
{
return;
}
forward = null;
running.ErrorOccurred -= OnForwardError;
try
{
await running.DisposeAsync().ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Stopping a listener has no failure anybody could act on; the connection closing would have
// stopped it regardless.
}
}
/// <remarks>
/// A connection through the forward failed — usually the target refusing it. The listener is still up,
/// so the row stays LISTENING and says what happened underneath.
/// </remarks>
private void OnForwardError(object? sender, SshPortForwardErrorEventArgs e) => post(() =>
{
if (State is PortForwardState.Listening)
{
Detail = e.Message;
}
});
private static string Port(int? port) => port?.ToString(CultureInfo.InvariantCulture) ?? "?";
}
@@ -1,3 +1,6 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using DodoSSH.Client.Ssh;
@@ -326,6 +329,73 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// <summary>How many steps there are, for the card's track.</summary>
internal int StepCount => Steps.Count;
/// <summary>
/// The host's saved forwards, as they run over this tab's current session.
/// </summary>
/// <remarks>
/// Replaced whole each time a session opens here — see <see cref="ReplaceForwards"/> — and emptied of
/// meaning, though not of rows, when it ends: a row that says it stopped with the session is more use
/// than one that silently disappeared.
/// </remarks>
internal ObservableCollection<PortForwardViewModel> Forwards { get; } = [];
/// <summary>How many of <see cref="Forwards"/> are listening now.</summary>
internal int ListeningForwardCount => Forwards.Count(forward => forward.IsListening);
/// <summary>
/// The status bar's forward count, or null when nothing is listening and there is nothing to say.
/// </summary>
internal string? ForwardsSummary => ListeningForwardCount switch
{
0 => null,
1 => "⇄ 1 FORWARD",
var count => string.Create(CultureInfo.InvariantCulture, $"⇄ {count} FORWARDS"),
};
/// <summary>Puts a new session's forwards on this tab, ending whatever the last session left.</summary>
internal void ReplaceForwards(IEnumerable<PortForwardViewModel> forwards)
{
EndForwards();
foreach (var forward in Forwards)
{
forward.PropertyChanged -= OnForwardChanged;
}
Forwards.Clear();
foreach (var forward in forwards)
{
forward.PropertyChanged += OnForwardChanged;
Forwards.Add(forward);
}
RaiseForwardCount();
}
/// <summary>Tells every forward its session is over; SSH.NET has already stopped the listeners.</summary>
private void EndForwards()
{
foreach (var forward in Forwards)
{
forward.SessionEnded();
}
}
private void OnForwardChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(PortForwardViewModel.State))
{
RaiseForwardCount();
}
}
private void RaiseForwardCount()
{
OnPropertyChanged(nameof(ListeningForwardCount));
OnPropertyChanged(nameof(ForwardsSummary));
}
/// <inheritdoc cref="TerminalTabState" />
[ObservableProperty]
private TerminalTabState state;
@@ -603,6 +673,8 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
EndDescription = description;
IsLive = false;
EndForwards();
OnPropertyChanged(nameof(EndedDetail));
OnPropertyChanged(nameof(EndedSummary));
OnPropertyChanged(nameof(EndDescription));
@@ -638,6 +710,9 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
EndDescription = null;
IsLive = false;
// The old session is being closed for its pane's sake, and its forwards go with its connection.
EndForwards();
// Cleared with the session they were facts about. Keeping them would put the previous connection's
// cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null;
@@ -594,6 +594,10 @@ internal sealed partial class HostRowViewModel(
/// <summary>Whether the card draws a pin-count badge at all.</summary>
internal bool HasPins => PinCount > 0;
/// <summary>Whether this host carries any port forwards, for the detail pane's FORWARDS card.</summary>
/// <remarks>Read off the secret for the reason <see cref="PinCount"/> is.</remarks>
internal bool HasForwards => host.Secret.PortForwards.Count > 0;
/// <summary>
/// The two letters a card's monogram avatar draws.
/// </summary>
@@ -956,8 +960,17 @@ internal sealed class TerminalSessionEventArgs(
string cipher,
string hostKeyAlgorithm,
string? identityLabel,
Guid? hostId) : EventArgs
Guid? hostId,
PortForwardList? forwards = null) : EventArgs
{
/// <summary>
/// The forwards to start on this session: the host's saved ones, or none for a typed-in address.
/// </summary>
/// <remarks>
/// Carried here because the shell has no vault to read them from, for the reason the label is.
/// </remarks>
internal PortForwardList Forwards { get; } = forwards ?? PortForwardList.Empty;
/// <summary>Which attempt this session came out of.</summary>
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
internal Guid AttemptId { get; } = attemptId;
@@ -3722,6 +3735,196 @@ internal sealed partial class VaultViewModel(
EditorNewPin = string.Empty;
}
/// <summary>
/// The port forwards on the host being edited, staged the way <see cref="EditorPinnedPaths"/> is.
/// </summary>
/// <remarks>
/// Populated when the editor opens, changed by <see cref="AddEditorForward"/> and
/// <see cref="RemoveEditorForward"/>, read back by <see cref="BuildHost"/> on Save. A rule keeps its id
/// through the editor, which is what lets the merge tell an edited forward from a new one.
/// </remarks>
internal ObservableCollection<PortForwardRule> EditorPortForwards { get; } = [];
/// <summary>The add row's kind, as the index of <see cref="PortForwardKind"/>: local, remote, dynamic.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorNewForwardHasTarget))]
[NotifyPropertyChangedFor(nameof(EditorNewForwardBindHint))]
[NotifyPropertyChangedFor(nameof(EditorNewForwardKindHint))]
private int editorNewForwardKind;
/// <summary>The add row's listening port, as typed.</summary>
[ObservableProperty]
private string editorNewForwardBindPort = string.Empty;
/// <summary>The add row's target, as <c>host:port</c>.</summary>
[ObservableProperty]
private string editorNewForwardTarget = string.Empty;
/// <summary>Whether the add row's kind has a target at all; a dynamic forward does not.</summary>
internal bool EditorNewForwardHasTarget => (PortForwardKind)EditorNewForwardKind is not PortForwardKind.Dynamic;
/// <summary>The add row's listening-port placeholder: short, because the box is narrow.</summary>
internal string EditorNewForwardBindHint => (PortForwardKind)EditorNewForwardKind switch
{
PortForwardKind.Remote => "remote port",
PortForwardKind.Dynamic => "SOCKS port",
_ => "local port",
};
/// <summary>
/// What the add row's kind does, in a sentence naming which end each port is on — the thing people get
/// backwards about <c>-L</c> and <c>-R</c>.
/// </summary>
internal string EditorNewForwardKindHint => (PortForwardKind)EditorNewForwardKind switch
{
PortForwardKind.Remote =>
"A port on the remote, carried back to a host:port as this machine sees it.",
PortForwardKind.Dynamic =>
"A SOCKS proxy on this machine. Each connection through it leaves from the remote.",
_ => "A port on this machine, carried to a host:port as the remote sees it.",
};
/// <summary>
/// Adds a forward to the host being edited, from the editor's add row.
/// </summary>
/// <remarks>
/// <para>
/// Validated here rather than at Save, for the reason <see cref="AddEditorPin"/> is: a refusal at the
/// box that caused it costs nothing else on the form. The rule's own
/// <see cref="PortForwardRule.TryValidate"/> is the check, so this cannot drift from what the codec
/// accepts.
/// </para>
/// <para>
/// <b>A second listener on a port this host already listens on is refused here</b>, though the domain
/// allows it — it has to, because two machines each adding one is what a merge produces. Here it is one
/// person, and the second would only ever fail to start.
/// </para>
/// </remarks>
[RelayCommand]
private void AddEditorForward()
{
var kind = (PortForwardKind)EditorNewForwardKind;
if (!int.TryParse(EditorNewForwardBindPort.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var bindPort))
{
Status = "A forward needs a listening port, as a number.";
return;
}
string? targetHost = null;
int? targetPort = null;
if (kind is not PortForwardKind.Dynamic)
{
if (!TryParseForwardTarget(EditorNewForwardTarget, out targetHost, out targetPort))
{
Status = "A forward's target is host:port, for example db.internal:5432.";
return;
}
}
var rule = new PortForwardRule
{
Id = Guid.CreateVersion7(),
Kind = kind,
BindPort = bindPort,
TargetHost = targetHost,
TargetPort = targetPort,
};
if (!rule.TryValidate(out var reason))
{
Status = reason;
return;
}
if (EditorPortForwards.Count >= HostSecret.MaxPortForwards)
{
Status = $"A host cannot carry more than {HostSecret.MaxPortForwards} port forwards.";
return;
}
// Local and dynamic forwards both listen on this machine, so they share its ports; a remote forward
// listens on the remote, which is a different set.
var listensHere = kind is not PortForwardKind.Remote;
if (EditorPortForwards.Any(existing =>
existing.BindPort == bindPort && existing.Kind is not PortForwardKind.Remote == listensHere))
{
Status = listensHere
? $"This host already has a forward listening on port {bindPort} here."
: $"This host already has a forward listening on port {bindPort} on the remote.";
return;
}
EditorPortForwards.Add(rule);
EditorNewForwardBindPort = string.Empty;
EditorNewForwardTarget = string.Empty;
Status = string.Empty;
}
/// <summary>Removes a forward from the host being edited.</summary>
[RelayCommand]
private void RemoveEditorForward(PortForwardRule? rule)
{
if (rule is not null)
{
EditorPortForwards.Remove(rule);
}
}
/// <summary>Stages an existing host's forwards for editing. See <see cref="EditorPortForwards"/>.</summary>
private void LoadEditorPortForwards(PortForwardList forwards)
{
EditorPortForwards.Clear();
foreach (var forward in forwards)
{
EditorPortForwards.Add(forward);
}
EditorNewForwardKind = (int)PortForwardKind.Local;
EditorNewForwardBindPort = string.Empty;
EditorNewForwardTarget = string.Empty;
}
/// <summary>
/// Reads <c>host:port</c>, with an IPv6 literal in brackets as OpenSSH writes one: <c>[::1]:5432</c>.
/// </summary>
/// <remarks>
/// Split at the last colon, so an unbracketed IPv6 address fails to parse as a port rather than being
/// cut somewhere in its middle. The host is checked by the rule, not here.
/// </remarks>
internal static bool TryParseForwardTarget(string text, out string? host, out int? port)
{
host = null;
port = null;
var trimmed = text.Trim();
var colon = trimmed.LastIndexOf(':');
if (colon <= 0
|| !int.TryParse(trimmed.AsSpan(colon + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var parsed))
{
return false;
}
var name = trimmed[..colon];
if (name.StartsWith('[') && name.EndsWith(']'))
{
name = name[1..^1];
}
else if (name.Contains(':', StringComparison.Ordinal))
{
return false;
}
host = name;
port = parsed;
return true;
}
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
@@ -7547,6 +7750,7 @@ internal sealed partial class VaultViewModel(
BuildTagChoices();
EditorPinnedPaths.Clear();
EditorNewPin = string.Empty;
LoadEditorPortForwards(PortForwardList.Empty);
// Closed rather than carried over, and it holds a password — see EditorNewCredentialPassword.
ClearEditorCredentialForm();
@@ -7640,6 +7844,7 @@ internal sealed partial class VaultViewModel(
ClearEditorCredentialForm();
LoadEditorPinnedPaths(row.Host.PinnedPaths);
LoadEditorPortForwards(row.Host.PortForwards);
BuildEditorVaultChoices(editingHostVaultId);
BuildGroupChoices(row.Host.GroupId);
@@ -11810,7 +12015,8 @@ internal sealed partial class VaultViewModel(
facts?.Cipher ?? string.Empty,
facts?.HostKeyAlgorithm ?? string.Empty,
authentication.IdentityLabel,
target.HostId));
target.HostId,
target.Row?.Host.PortForwards));
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear.
@@ -12367,6 +12573,9 @@ internal sealed partial class VaultViewModel(
// than twice.
PinnedPaths = PinnedPathList.Create(EditorPinnedPaths),
// The staged list, in the order it was built; each rule keeps the id it arrived with.
PortForwards = PortForwardList.Create(EditorPortForwards),
// Both read off the one picker, including the id of something that has gone missing. Reading them
// from the picker rather than carrying the originals through is what lets a binding be removed at
// all, and preserving a missing id is what stops an unrelated edit removing one by accident. One
+9
View File
@@ -190,6 +190,15 @@ public interface ISshConnection : IAsyncDisposable
/// <summary>Opens an interactive shell with a pseudo-terminal.</summary>
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
/// <summary>Starts a port forward over this connection.</summary>
/// <remarks>
/// Runs beside the shell on the same transport, as <c>ssh -L</c> does, and stops when the connection
/// does. Any number may be started; each is its own listener and fails on its own.
/// </remarks>
/// <exception cref="ArgumentException">The spec is not valid; see <see cref="SshPortForwardSpec.TryValidate"/>.</exception>
/// <exception cref="SshPortForwardException">The forward could not be started, with a message saying why.</exception>
Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken);
}
/// <summary>
@@ -417,6 +417,14 @@ internal sealed class SshNetConnection(
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
}
/// <inheritdoc />
public Task<ISshPortForward> StartForwardAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(spec);
return SshNetPortForward.StartAsync(client, spec, cancellationToken);
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
+223
View File
@@ -0,0 +1,223 @@
using System.Globalization;
using System.Net;
using System.Net.Sockets;
using Renci.SshNet;
using Renci.SshNet.Common;
namespace DodoSSH.Client.Ssh;
/// <summary>A forward over an SSH.NET client.</summary>
/// <remarks>
/// <para>
/// One class for all three kinds, because SSH.NET's three share a base with the only members this needs —
/// <c>Start</c>, <c>Stop</c>, <c>IsStarted</c> and the <c>Exception</c> event — and differ only in how they
/// are built and where their bound port is read from.
/// </para>
/// <para>
/// ◆ <b>Started and stopped off the calling thread.</b> A remote forward's <c>Start</c> sends
/// <c>tcpip-forward</c> and blocks until the server answers, and every kind's <c>Stop</c> waits for its open
/// channels to drain. Either would otherwise hold the UI thread for a round trip or longer.
/// </para>
/// </remarks>
internal sealed class SshNetPortForward : ISshPortForward
{
/// <summary>Where every listener on this machine binds. See <see cref="SshPortForwardSpec"/>.</summary>
private const string LoopbackAddress = "127.0.0.1";
private readonly SshClient client;
private readonly ForwardedPort port;
private string? lastError;
private int disposed;
private SshNetPortForward(SshClient client, ForwardedPort port, SshPortForwardSpec spec)
{
this.client = client;
this.port = port;
Spec = spec;
port.Exception += OnException;
}
/// <inheritdoc />
public event EventHandler<SshPortForwardErrorEventArgs>? ErrorOccurred;
/// <inheritdoc />
public SshPortForwardSpec Spec { get; }
/// <inheritdoc />
public int BoundPort => port switch
{
ForwardedPortLocal local => (int)local.BoundPort,
ForwardedPortDynamic dynamic => (int)dynamic.BoundPort,
ForwardedPortRemote remote => (int)remote.BoundPort,
_ => Spec.BindPort,
};
/// <inheritdoc />
/// <remarks>
/// Read off the port rather than the client: SSH.NET stops every forward on a session that ends, and
/// <c>SshClient.IsConnected</c> throws rather than answering once the connection has been disposed.
/// </remarks>
public bool IsListening => Volatile.Read(ref disposed) == 0 && port.IsStarted;
/// <inheritdoc />
public string? LastError => Volatile.Read(ref lastError);
/// <summary>Starts a forward on a connected client.</summary>
/// <exception cref="ArgumentException">The spec is not valid.</exception>
/// <exception cref="SshPortForwardException">The forward could not be started.</exception>
internal static async Task<ISshPortForward> StartAsync(
SshClient client,
SshPortForwardSpec spec,
CancellationToken cancellationToken)
{
if (!spec.TryValidate(out var reason))
{
throw new ArgumentException(reason, nameof(spec));
}
var listener = await CreateAsync(spec, cancellationToken).ConfigureAwait(false);
var forward = new SshNetPortForward(client, listener, spec);
try
{
client.AddForwardedPort(listener);
await Task.Run(listener.Start, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
await forward.DisposeAsync().ConfigureAwait(false);
throw new SshPortForwardException(Explain(spec, exception), exception);
}
catch
{
await forward.DisposeAsync().ConfigureAwait(false);
throw;
}
return forward;
}
/// <inheritdoc />
/// <remarks>
/// Safe on a client that has already gone: SSH.NET stops its forwards when the session ends, and what
/// is left to do then is let go of the port object, which cannot fail in a way anybody could act on.
/// </remarks>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
port.Exception -= OnException;
await Task.Run(() =>
{
try
{
client.RemoveForwardedPort(port);
}
catch (Exception exception) when (exception is ObjectDisposedException or SshException
or SocketException or InvalidOperationException)
{
// The client or its session is already gone, and took the listener with it.
}
port.Dispose();
}).ConfigureAwait(false);
}
/// <remarks>
/// <para>
/// A remote forward's target is resolved here, asynchronously, rather than by SSH.NET. Its string
/// constructor resolves both addresses synchronously and takes whichever comes first, so
/// <c>localhost</c> could become <c>::1</c> — which a service listening on <c>127.0.0.1</c> alone
/// refuses — and the bound address could be sent to the remote as an IPv6 literal it has no reason to
/// expect. IPv4 is preferred for the target for the first reason, and the bound address is the IPv4
/// loopback literal for the second.
/// </para>
/// <para>
/// A local forward's target is not resolved at all. It travels to the remote as a name in the
/// <c>direct-tcpip</c> request, which is what lets it name a machine only the remote can see.
/// </para>
/// </remarks>
private static async Task<ForwardedPort> CreateAsync(SshPortForwardSpec spec, CancellationToken cancellationToken)
{
switch (spec.Kind)
{
case SshPortForwardKind.Local:
return new ForwardedPortLocal(
LoopbackAddress, (uint)spec.BindPort, spec.TargetHost!, (uint)spec.TargetPort);
case SshPortForwardKind.Dynamic:
return new ForwardedPortDynamic(LoopbackAddress, (uint)spec.BindPort);
default:
var target = await ResolveAsync(spec.TargetHost!, cancellationToken).ConfigureAwait(false);
return new ForwardedPortRemote(IPAddress.Loopback, (uint)spec.BindPort, target, (uint)spec.TargetPort);
}
}
private static async Task<IPAddress> ResolveAsync(string host, CancellationToken cancellationToken)
{
if (IPAddress.TryParse(host, out var literal))
{
return literal;
}
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken).ConfigureAwait(false);
}
catch (SocketException exception)
{
throw new SshPortForwardException($"'{host}' could not be resolved on this machine.", exception);
}
return addresses.FirstOrDefault(address => address.AddressFamily is AddressFamily.InterNetwork)
?? addresses.FirstOrDefault()
?? throw new SshPortForwardException($"'{host}' could not be resolved on this machine.");
}
/// <summary>Says which port could not be had, and where, rather than repeating a socket error code.</summary>
private static string Explain(SshPortForwardSpec spec, Exception exception)
{
var bind = spec.BindPort.ToString(CultureInfo.InvariantCulture);
return exception switch
{
SshPortForwardException own => own.Message,
SocketException { SocketErrorCode: SocketError.AddressAlreadyInUse } =>
$"Port {bind} on this machine is already in use.",
SocketException { SocketErrorCode: SocketError.AccessDenied } =>
$"This machine would not let DodoSSH listen on port {bind}.",
// What SSH.NET throws when the server answers tcpip-forward with a refusal. The server gives no
// reason, so neither can this — but the likely ones are worth naming.
SshException when spec.Kind is SshPortForwardKind.Remote =>
$"The remote would not listen on port {bind}. It may be in use there, below 1024, or "
+ "forbidden by the server's AllowTcpForwarding setting.",
_ => exception.Message,
};
}
/// <remarks>
/// Raised by SSH.NET on its own threads for a connection through the forward that failed — most often a
/// target that refused it. The listener stays up; see <see cref="ISshPortForward.LastError"/>.
/// </remarks>
private void OnException(object? sender, ExceptionEventArgs e)
{
Volatile.Write(ref lastError, e.Exception.Message);
ErrorOccurred?.Invoke(this, new SshPortForwardErrorEventArgs(e.Exception.Message));
}
}
+164
View File
@@ -0,0 +1,164 @@
using System.Diagnostics.CodeAnalysis;
namespace DodoSSH.Client.Ssh;
/// <summary>Which way a forward carries connections.</summary>
/// <remarks>The three OpenSSH has, named for the flag each one is: <c>-L</c>, <c>-R</c> and <c>-D</c>.</remarks>
public enum SshPortForwardKind
{
/// <summary>
/// A port on this machine, carried to a host and port as the remote sees them. <c>ssh -L</c>.
/// </summary>
Local = 0,
/// <summary>
/// A port on the remote, carried back to a host and port as this machine sees them. <c>ssh -R</c>.
/// </summary>
Remote = 1,
/// <summary>
/// A SOCKS proxy on this machine, whose every connection leaves from the remote. <c>ssh -D</c>.
/// </summary>
Dynamic = 2,
}
/// <summary>One forward to run over a connection.</summary>
/// <param name="Kind">Which way it carries connections.</param>
/// <param name="BindPort">
/// The port to listen on: on this machine for <see cref="SshPortForwardKind.Local"/> and
/// <see cref="SshPortForwardKind.Dynamic"/>, on the remote for <see cref="SshPortForwardKind.Remote"/>. Zero
/// asks for an ephemeral port, which only a listener on this machine can report back — see
/// <see cref="TryValidate"/>.
/// </param>
/// <param name="TargetHost">
/// Where each connection goes: resolved by the remote for a local forward and by this machine for a remote
/// one. Null for a dynamic forward, whose target is whatever each SOCKS client asks for.
/// </param>
/// <param name="TargetPort">The port on <paramref name="TargetHost"/>; zero for a dynamic forward.</param>
/// <remarks>
/// <para>
/// ◆ <b>There is no bind address, and that is the point of the type.</b> Every listener this client opens
/// is on loopback, for the reason <see cref="SshLoopbackProxy"/> gives: a forward bound to any other
/// interface hands the remote's network to whoever else can reach this machine, for as long as the session
/// is up. <c>ssh -L 0.0.0.0:…</c> is a flag somebody types knowing what it does; a field in a synced host
/// that another member of the keychain filled in is not. Leaving the address out makes that
/// unrepresentable rather than merely discouraged.
/// </para>
/// <para>
/// A remote forward asks for the remote's loopback too. Whether the remote honours that is its
/// <c>GatewayPorts</c> setting and not this client's to decide, which is the same arrangement OpenSSH has.
/// </para>
/// </remarks>
public sealed record SshPortForwardSpec(
SshPortForwardKind Kind,
int BindPort,
string? TargetHost = null,
int TargetPort = 0)
{
/// <summary>Checks the spec can be started as it stands.</summary>
public bool TryValidate([NotNullWhen(false)] out string? reason)
{
if (!Enum.IsDefined(Kind))
{
reason = $"'{Kind}' is not a kind of forward.";
return false;
}
// A remote listener's port is chosen by the remote, and SSH.NET never reads the one it chose back,
// so an ephemeral remote forward would be listening somewhere nobody could name.
var lowest = Kind is SshPortForwardKind.Remote ? 1 : 0;
if (BindPort < lowest || BindPort > 65535)
{
reason = $"The listening port must be between {lowest} and 65535, not {BindPort}.";
return false;
}
if (Kind is SshPortForwardKind.Dynamic)
{
if (TargetHost is not null || TargetPort != 0)
{
reason = "A dynamic forward has no target: each connection through it names its own.";
return false;
}
reason = null;
return true;
}
if (string.IsNullOrWhiteSpace(TargetHost))
{
reason = "A forward needs a host to forward to.";
return false;
}
if (TargetPort is < 1 or > 65535)
{
reason = $"The target port must be between 1 and 65535, not {TargetPort}.";
return false;
}
reason = null;
return true;
}
}
/// <summary>Why a forward is no longer carrying a connection it accepted.</summary>
/// <param name="message">What went wrong, as SSH.NET put it.</param>
public sealed class SshPortForwardErrorEventArgs(string message) : EventArgs
{
/// <summary>What went wrong.</summary>
public string Message { get; } = message;
}
/// <summary>A forward that is listening, and stops when disposed.</summary>
/// <remarks>
/// <para>
/// Owned by whoever started it, and also stopped by the connection it runs over: a connection that closes
/// takes every forward on it down with it, so disposing one afterwards is safe and does nothing.
/// </para>
/// <para>
/// ◆ <b>No byte counts.</b> SSH.NET copies a forwarded channel's bytes inside its own channel classes and
/// exposes no count of them, so a figure here would be an estimate presented as a measurement.
/// </para>
/// </remarks>
public interface ISshPortForward : IAsyncDisposable
{
/// <summary>What was asked for.</summary>
SshPortForwardSpec Spec { get; }
/// <summary>
/// The port actually listened on, which differs from <see cref="SshPortForwardSpec.BindPort"/> only when
/// that asked for an ephemeral one.
/// </summary>
int BoundPort { get; }
/// <summary>Whether the listener is still up.</summary>
/// <remarks>
/// False once disposed, and once the connection under it has gone — SSH.NET stops every forward on a
/// session that ends.
/// </remarks>
bool IsListening { get; }
/// <summary>
/// The most recent failure to carry a connection, or null if there has not been one.
/// </summary>
/// <remarks>
/// A failure here does not stop the listener. The usual cause is the target refusing one connection —
/// a database that is down, a port nothing is on — and the next connection may well succeed.
/// </remarks>
string? LastError { get; }
/// <summary>
/// Raised when a connection through this forward fails, on whichever thread SSH.NET noticed it.
/// </summary>
event EventHandler<SshPortForwardErrorEventArgs>? ErrorOccurred;
}
/// <summary>A forward that could not be started, with a sentence saying why.</summary>
/// <remarks>
/// What SSH.NET throws for the two commonest refusals is a socket error code and "failed to start", neither
/// of which names the port. The message here does, and the original is kept as the inner exception.
/// </remarks>
public sealed class SshPortForwardException(string message, Exception? innerException = null)
: Exception(message, innerException);
+1
View File
@@ -273,6 +273,7 @@ internal sealed class HostKind : IItemKind<HostSecret>
Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
Note(changed, "Group", before.GroupId, after.GroupId);
Note(changed, "Tags", before.TagIds, after.TagIds);
Note(changed, "Port forwards", before.PortForwards, after.PortForwards);
return changed;
}
@@ -550,6 +550,51 @@ public sealed class TerminalWorkspace : IAsyncDisposable
return sessionId;
}
/// <summary>
/// Starts a port forward over one terminal's connection.
/// </summary>
/// <param name="sessionId">The terminal whose connection the forward runs over.</param>
/// <param name="spec">What to forward.</param>
/// <param name="cancellationToken">Abandons the start.</param>
/// <returns>The forward, which the caller may dispose to stop it sooner.</returns>
/// <remarks>
/// <para>
/// <b>The forward lives as long as the session, and this object does not keep a list of them.</b> SSH.NET
/// stops every forward on a connection when the connection is disposed, which is what both
/// <see cref="CloseSessionAsync"/> and the release of a session that ended on its own already do — so a
/// second record here would be a copy of a fact the library already enforces, and the one that could
/// disagree with it. Disposing a handle after its session has gone is safe.
/// </para>
/// <para>
/// Refused for a session that is not running, for the reason <see cref="PasteAsync"/> refuses one: a tab
/// whose shell ended is still on screen, and a forward started on its connection would be a listener
/// over a transport that is already being torn down.
/// </para>
/// </remarks>
/// <exception cref="InvalidOperationException">The session is not running.</exception>
/// <exception cref="SshPortForwardException">The forward could not be started.</exception>
public Task<ISshPortForward> StartForwardAsync(
uint sessionId,
SshPortForwardSpec spec,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(spec);
ISshConnection connection;
lock (sessionGate)
{
if (!sessions.TryGetValue(sessionId, out var session) || session.Run.IsCompleted)
{
throw new InvalidOperationException("That terminal is no longer connected.");
}
connection = session.Connection;
}
return connection.StartForwardAsync(spec, cancellationToken);
}
/// <summary>
/// Shows one terminal's pane and hides the others.
/// </summary>