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