Public Access
Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
This commit is contained in:
commit
689ba6c1bf
44 files changed
+3187
-75
No files matched your search
@@ -1241,6 +1241,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal event EventHandler? TerminalSessionOpened;
|
||||
|
||||
/// <summary>
|
||||
/// Raised when any tab's forward starts, stops or fails, so a head can re-count what is listening.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The phone's keep-alive notification is the one listener: it says how many forwards are listening
|
||||
/// alongside how many shells are connected. On the UI thread, since every state change is.
|
||||
/// </remarks>
|
||||
internal event EventHandler? ForwardsChanged;
|
||||
|
||||
/// <summary>How many forwards are listening, across every tab.</summary>
|
||||
internal int ListeningForwardCount => Tabs.Sum(tab => tab.ListeningForwardCount);
|
||||
|
||||
/// <summary>
|
||||
/// Raised when something this shell did belongs in the terminal the user is already looking at, so the
|
||||
/// view can put the keyboard back there.
|
||||
@@ -1487,6 +1499,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
internal bool IsTerminalShowing =>
|
||||
IsTerminalSurface
|
||||
&& !IsConnectSheetOpen
|
||||
&& !IsForwardsSheetOpen
|
||||
&& !IsHostKeyDecisionShowing
|
||||
&& SelectedTab is { HasSession: true };
|
||||
|
||||
@@ -2232,6 +2245,45 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[RelayCommand]
|
||||
private void CloseConnectSheet() => IsConnectSheetOpen = false;
|
||||
|
||||
// ---- The phone's forwards sheet ----
|
||||
|
||||
/// <summary>
|
||||
/// Whether the phone's list of the selected tab's forwards is open over the terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The phone's answer to the desktop sidebar's FORWARDS section, over the same rows —
|
||||
/// <see cref="TerminalTabViewModel.Forwards"/> — with the same state word, reason and switch. A sheet
|
||||
/// rather than something always drawn because a phone gives a shell the whole screen, so it is raised
|
||||
/// from a button in the terminal's bar, which draws only for a tab that has forwards and turns the danger
|
||||
/// colour when one of them failed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Shell state for the reason <see cref="IsConnectSheetOpen"/> is: it collapses the renderer, which is a
|
||||
/// native view nothing can be drawn over. Lowered with the connect sheet whenever the terminal surface is
|
||||
/// left — see <see cref="OnSurfaceChanged"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isForwardsSheetOpen;
|
||||
|
||||
/// <summary>Raises the forwards sheet over the terminal.</summary>
|
||||
/// <remarks>Gated as <see cref="OpenConnectSheet"/> is, and on there being a tab to list.</remarks>
|
||||
[RelayCommand]
|
||||
private void OpenForwardsSheet()
|
||||
{
|
||||
if (!IsTerminalSurface || SelectedTab is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsForwardsSheetOpen = true;
|
||||
}
|
||||
|
||||
/// <summary>Lowers the forwards sheet, leaving the terminal where it was.</summary>
|
||||
[RelayCommand]
|
||||
private void CloseForwardsSheet() => IsForwardsSheetOpen = false;
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
/// <summary>
|
||||
@@ -3868,11 +3920,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.
|
||||
@@ -3894,6 +3948,54 @@ 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>On the phone too.</b> This used to return early on Android, because that head had nowhere to show
|
||||
/// what was listening. Its terminal bar now carries a FORWARDS button over the same rows the desktop's
|
||||
/// sidebar lists — see <see cref="IsForwardsSheetOpen"/> — so both heads start the same forwards.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void StartForwards(TerminalTabViewModel tab, TerminalSessionEventArgs e)
|
||||
{
|
||||
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)
|
||||
{
|
||||
// Never unsubscribed, and it need not be: the row holds the handler, not the other way round, so a
|
||||
// row replaced by a reconnect is collected with it — after its last change, to Stopped, is heard.
|
||||
forward.PropertyChanged += OnForwardChanged;
|
||||
_ = forward.StartAsync();
|
||||
}
|
||||
|
||||
ForwardsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void OnForwardChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(PortForwardViewModel.State))
|
||||
{
|
||||
ForwardsChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Answers a connection that did not become a session.
|
||||
/// </summary>
|
||||
@@ -4694,6 +4796,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>
|
||||
@@ -5169,6 +5282,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
if (value is not ShellSurface.Terminal)
|
||||
{
|
||||
IsConnectSheetOpen = false;
|
||||
IsForwardsSheetOpen = false;
|
||||
}
|
||||
|
||||
RaiseSurfaceState();
|
||||
@@ -5353,6 +5467,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <inheritdoc cref="OnIsSearchingChanged" />
|
||||
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
|
||||
|
||||
/// <inheritdoc cref="OnIsSearchingChanged" />
|
||||
partial void OnIsForwardsSheetOpenChanged(bool value) => RaiseTerminalState();
|
||||
|
||||
/// <remarks>
|
||||
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
||||
/// <see cref="IsAskingForThePassphrase"/>.
|
||||
|
||||
@@ -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,90 @@ 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>Whether any of <see cref="Forwards"/> is listening now.</summary>
|
||||
internal bool HasListeningForward => ListeningForwardCount > 0;
|
||||
|
||||
/// <summary>Whether this tab carries any forwards at all, running or not.</summary>
|
||||
/// <remarks>The phone's terminal bar draws its FORWARDS button only for a tab that has some.</remarks>
|
||||
internal bool HasForwards => Forwards.Count > 0;
|
||||
|
||||
/// <summary>Whether any of <see cref="Forwards"/> could not be started.</summary>
|
||||
/// <remarks>
|
||||
/// The phone's list is a sheet, not a sidebar that is always drawn, so a refusal there would be invisible
|
||||
/// until somebody happened to open it. This is what lets the button that opens it say so first.
|
||||
/// </remarks>
|
||||
internal bool HasFailedForward => Forwards.Any(forward => forward.IsFailed);
|
||||
|
||||
/// <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));
|
||||
OnPropertyChanged(nameof(HasListeningForward));
|
||||
OnPropertyChanged(nameof(HasForwards));
|
||||
OnPropertyChanged(nameof(HasFailedForward));
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="TerminalTabState" />
|
||||
[ObservableProperty]
|
||||
private TerminalTabState state;
|
||||
@@ -603,6 +690,8 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
EndDescription = description;
|
||||
IsLive = false;
|
||||
|
||||
EndForwards();
|
||||
|
||||
OnPropertyChanged(nameof(EndedDetail));
|
||||
OnPropertyChanged(nameof(EndedSummary));
|
||||
OnPropertyChanged(nameof(EndDescription));
|
||||
@@ -638,6 +727,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;
|
||||
@@ -3723,6 +3736,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;
|
||||
|
||||
@@ -7552,6 +7755,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();
|
||||
@@ -7646,6 +7850,7 @@ internal sealed partial class VaultViewModel(
|
||||
ClearEditorCredentialForm();
|
||||
|
||||
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
||||
LoadEditorPortForwards(row.Host.PortForwards);
|
||||
|
||||
BuildEditorVaultChoices(editingHostVaultId);
|
||||
BuildGroupChoices(row.Host.GroupId);
|
||||
@@ -11817,7 +12022,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.
|
||||
@@ -12381,6 +12587,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
|
||||
|
||||
Reference in new issue
Block a user