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