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

@@ -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