|
|
|
@@ -136,6 +136,7 @@ internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal sealed partial class HostRowViewModel(
|
|
|
|
|
VaultItem<HostSecret> host,
|
|
|
|
|
ResolvedHost resolved,
|
|
|
|
|
Guid vaultId,
|
|
|
|
|
string vaultName) : ObservableObject, ISidebarRow
|
|
|
|
|
{
|
|
|
|
@@ -170,11 +171,38 @@ internal sealed partial class HostRowViewModel(
|
|
|
|
|
|
|
|
|
|
internal HostSecret Host => host.Secret;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The same host with its group chain applied: what it dials, not what was typed into it.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Resolved once, when the list is built, rather than per property. Every label on this row wants the
|
|
|
|
|
/// same three answers, and re-walking the chain for each of them would be a walk per property per redraw.
|
|
|
|
|
/// It also means a row cannot disagree with itself — the address the list shows and the port the connect
|
|
|
|
|
/// command dials come from one value.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal ResolvedHost Resolved => resolved;
|
|
|
|
|
|
|
|
|
|
internal string Label => host.Secret.Label;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// What this row dials, as one string.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The <em>resolved</em> address, which is not decoration: <c>MainWindowViewModel.Rank</c> searches this,
|
|
|
|
|
/// so a host inheriting 2222 that displayed 22 would be unfindable by the port it actually answers on —
|
|
|
|
|
/// and the user would be searching for the number the machine really uses.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal string Address => string.Create(
|
|
|
|
|
CultureInfo.InvariantCulture,
|
|
|
|
|
$"{host.Secret.Username ?? "—"}@{host.Secret.Hostname}:{host.Secret.Port}");
|
|
|
|
|
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
|
|
|
|
|
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
|
|
|
|
|
/// with a message asking for one.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private string DisplayUsername =>
|
|
|
|
|
string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value;
|
|
|
|
|
|
|
|
|
|
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
|
|
|
|
|
|
|
|
|
@@ -184,15 +212,24 @@ internal sealed partial class HostRowViewModel(
|
|
|
|
|
|
|
|
|
|
/// <summary>How this host authenticates, in one word.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Worth a word in the list because the three behave differently at the moment of connecting: only one of
|
|
|
|
|
/// them needs the password box filled in, and a user staring at an empty password box on a
|
|
|
|
|
/// key-authenticated host has no other way to know it is not needed. "password" is the typed kind, which
|
|
|
|
|
/// is why the stored kind is "credential" rather than a second sort of password.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Read from the resolved binding rather than the host's own two ids, because the question this answers
|
|
|
|
|
/// is "will the password box be used" — and a host that inherits its group's key would otherwise say
|
|
|
|
|
/// "password" while quietly not needing one. Where the binding came from a group the word is the same;
|
|
|
|
|
/// the group is named in <see cref="VaultViewModel.SelectedHostAuthenticationNote"/>, which has room for
|
|
|
|
|
/// a sentence rather than a word.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal string Authentication => host.Secret switch
|
|
|
|
|
internal string Authentication => resolved.Binding.Kind switch
|
|
|
|
|
{
|
|
|
|
|
{ CredentialId: not null } => "credential",
|
|
|
|
|
{ SshKeyId: not null } => "key",
|
|
|
|
|
ResolvedBindingKind.Credential => "credential",
|
|
|
|
|
ResolvedBindingKind.SshKey => "key",
|
|
|
|
|
_ => "password",
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
@@ -229,6 +266,17 @@ internal enum AuthenticationKind
|
|
|
|
|
|
|
|
|
|
/// <summary>A username and password in this vault.</summary>
|
|
|
|
|
Credential,
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Whatever the group above this host says, or a typed password if it says nothing.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The entry that arrived with inheritance, and the reason <see cref="HostSecret.AsksForPassword"/>
|
|
|
|
|
/// exists. Naming neither a key nor a credential used to be the way to say "type one each time"; it now
|
|
|
|
|
/// means this, so the old meaning needs a value of its own — otherwise a host under a group that binds a
|
|
|
|
|
/// key could not opt out of it.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
Inherited,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>An entry in the host editor's authentication picker.</summary>
|
|
|
|
@@ -270,6 +318,26 @@ internal sealed record AuthenticationChoice(
|
|
|
|
|
internal static AuthenticationChoice Typed { get; } =
|
|
|
|
|
new(AuthenticationKind.Typed, null, "Password (ask each time)", string.Empty);
|
|
|
|
|
|
|
|
|
|
/// <summary>The "whatever the group says" entry, offered only to a host that is in one.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Named for where the answer comes from rather than for what it will be, because what it will be
|
|
|
|
|
/// changes when somebody edits the group — which is the point of it. It is deliberately not offered to
|
|
|
|
|
/// an ungrouped host: with nothing above it this and <see cref="Typed"/> do the same thing, and two
|
|
|
|
|
/// entries that behave identically are two entries a user has to guess between.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal static AuthenticationChoice Inherited { get; } =
|
|
|
|
|
new(AuthenticationKind.Inherited, null, "Inherit from group", string.Empty);
|
|
|
|
|
|
|
|
|
|
/// <summary>The "this group lends no binding" entry, first in the group editor's picker.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// A sentinel rather than a null selection, for the reason <see cref="Typed"/> is one: a picker showing
|
|
|
|
|
/// nothing and a group that deliberately lends nothing look identical and are not the same thing. It is
|
|
|
|
|
/// not <see cref="Typed"/> under another name — a group cannot assert "everything under here types its
|
|
|
|
|
/// password", because that is already what a host gets when the chain lends nothing.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal static AuthenticationChoice NoDefault { get; } =
|
|
|
|
|
new(AuthenticationKind.Typed, null, "No default binding", string.Empty);
|
|
|
|
|
|
|
|
|
|
/// <summary>An SSH key that is in the vault.</summary>
|
|
|
|
|
internal static AuthenticationChoice ForKey(Guid entityId, string label) =>
|
|
|
|
|
new(AuthenticationKind.SshKey, entityId, label, "SSH key");
|
|
|
|
@@ -812,8 +880,7 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
/// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host
|
|
|
|
|
/// would be one allocation per row per redraw.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private IReadOnlyDictionary<Guid, HostGroupSecret> groupsById =
|
|
|
|
|
new Dictionary<Guid, HostGroupSecret>();
|
|
|
|
|
private Dictionary<Guid, HostGroupSecret> groupsById = [];
|
|
|
|
|
|
|
|
|
|
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
|
|
|
|
|
private readonly HashSet<Guid> collapsedGroups = [];
|
|
|
|
@@ -976,6 +1043,43 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string groupEditorLabel = string.Empty;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The port hosts in this group take when they state none, empty for no default.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Empty is a real answer here rather than a missing one: a group that states no port lends none, and
|
|
|
|
|
/// the hosts beneath it walk further up. Distinguishing that from 22 is the difference between "these
|
|
|
|
|
/// machines are on 22" and "these machines have not been told".
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private int? groupEditorDefaultPort;
|
|
|
|
|
|
|
|
|
|
/// <summary>The user hosts in this group log in as when they state none.</summary>
|
|
|
|
|
/// <inheritdoc cref="GroupEditorDefaultPort" path="/remarks" />
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string groupEditorDefaultUsername = string.Empty;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// What the group's parent picker offers: "no parent", then every group that may legally be one.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
|
|
|
|
internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private GroupChoice? groupEditorSelectedParent;
|
|
|
|
|
|
|
|
|
|
/// <summary>What the group's authentication picker offers, for the hosts beneath it.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// The host picker's list without its first entry. A group cannot default to "ask for a password each
|
|
|
|
|
/// time": that is what a host beneath it gets when nothing in the chain lends a binding, so the entry
|
|
|
|
|
/// would be indistinguishable from leaving this alone — and two controls that do the same thing is one
|
|
|
|
|
/// control and a guess. "No default" is the sentinel instead.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal ObservableCollection<AuthenticationChoice> GroupEditorAuthenticationChoices { get; } = [];
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private AuthenticationChoice? groupEditorSelectedAuthentication;
|
|
|
|
|
|
|
|
|
|
/// <summary>The group being renamed, or null when the box would create one.</summary>
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private Guid? editingGroupId;
|
|
|
|
@@ -1176,8 +1280,18 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string editorHostname = string.Empty;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The port box, empty when the host is to take its group's.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Nullable rather than defaulting to 22, which is the difference between a form that states a port and
|
|
|
|
|
/// one that leaves it to the group. An empty box shows <see cref="EditorPortPlaceholder"/>, so the form
|
|
|
|
|
/// says what leaving it blank will get you rather than making the user guess — and a new host under a
|
|
|
|
|
/// group that says 2222 is created wanting 2222, without anybody typing it.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private int editorPort = HostSecret.DefaultPort;
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
|
|
|
|
|
private int? editorPort;
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private string editorUsername = string.Empty;
|
|
|
|
@@ -1207,8 +1321,92 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
|
|
|
|
|
[NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))]
|
|
|
|
|
private GroupChoice? editorSelectedGroup;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Keeps the authentication picker in step with whether there is a group to inherit from.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Filing a host into a group must not pin it to a typed password, and without this it did.</b> An
|
|
|
|
|
/// ungrouped host is offered no "Inherit from group" entry — with nothing above it the two entries would
|
|
|
|
|
/// behave identically — so its picker sits on "Password (ask each time)". Choose a group and save, and
|
|
|
|
|
/// that selection would be written as <see cref="HostSecret.AsksForPassword"/>: the host would be pinned
|
|
|
|
|
/// to a prompt it never asked for, by a user who was only filing it, and the group's key would never
|
|
|
|
|
/// reach it.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The selection moves to "Inherit from group" rather than staying, because for an ungrouped host the
|
|
|
|
|
/// two are the same thing and the stored value was "nothing stated". Reading a deliberate refusal into
|
|
|
|
|
/// a choice the user could not have made differently would be inventing an intent; inheriting is what
|
|
|
|
|
/// the record already said.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
partial void OnEditorSelectedGroupChanged(GroupChoice? value)
|
|
|
|
|
{
|
|
|
|
|
var grouped = value?.EntityId is not null;
|
|
|
|
|
|
|
|
|
|
if (grouped == EditorAuthenticationChoices.Contains(AuthenticationChoice.Inherited))
|
|
|
|
|
{
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var selected = EditorSelectedAuthentication;
|
|
|
|
|
|
|
|
|
|
BuildAuthenticationChoices(
|
|
|
|
|
Bound(AuthenticationKind.SshKey),
|
|
|
|
|
Bound(AuthenticationKind.Credential),
|
|
|
|
|
asksForPassword: !grouped && selected?.Kind == AuthenticationKind.Typed,
|
|
|
|
|
grouped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>What an empty port box will dial.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Recomputed when the group picker moves, which is the whole point of it being a placeholder rather
|
|
|
|
|
/// than a pre-filled value: filing a host into a group is supposed to visibly change what leaving the
|
|
|
|
|
/// box empty means. A pre-filled 2222 would have been indistinguishable from a port the user typed, and
|
|
|
|
|
/// saving it would have pinned it.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Reads the group chain from the picker's current selection rather than from the host being edited,
|
|
|
|
|
/// because the two differ for exactly as long as the editor is open and unsaved — which is when this is
|
|
|
|
|
/// read.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal string EditorPortPlaceholder =>
|
|
|
|
|
InheritedFromEditorGroup(group => group.DefaultPort?.ToString(CultureInfo.InvariantCulture))
|
|
|
|
|
?? HostSecret.DefaultPort.ToString(CultureInfo.InvariantCulture);
|
|
|
|
|
|
|
|
|
|
/// <summary>What an empty username box will log in as, or a prompt when nothing supplies one.</summary>
|
|
|
|
|
/// <inheritdoc cref="EditorPortPlaceholder" path="/remarks" />
|
|
|
|
|
internal string EditorUsernamePlaceholder =>
|
|
|
|
|
InheritedFromEditorGroup(group => group.DefaultUsername) ?? "username";
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Walks through <see cref="HostInheritance.Chain"/> rather than looking only at the selected group, so
|
|
|
|
|
/// a placeholder shows the value that will actually be used — which may come from three levels up — and
|
|
|
|
|
/// so that a cycle assembled elsewhere cannot hang the editor either.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private string? InheritedFromEditorGroup(Func<HostGroupSecret, string?> read) =>
|
|
|
|
|
HostInheritance
|
|
|
|
|
.Chain(EditorSelectedGroup?.EntityId, groupsById)
|
|
|
|
|
.Select(entry => read(entry.Group))
|
|
|
|
|
.FirstOrDefault(value => value is not null);
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The tags the host being edited already wears, carried through a save untouched.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Not a box on this head — neither head can put a tag on a host yet — and held anyway, because
|
|
|
|
|
/// <see cref="BuildHost"/> rebuilds the whole record from the editor's state. Left out, editing a port
|
|
|
|
|
/// would strip every tag a teammate had added from a client that can set them, which is the same class
|
|
|
|
|
/// of silent loss that <c>HostSecretDocument.IsReadOnly</c> exists to prevent.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private TagSet editorTagIds = TagSet.Empty;
|
|
|
|
|
|
|
|
|
|
/// <summary>The item being edited, or null when creating.</summary>
|
|
|
|
|
private Guid? editingEntityId;
|
|
|
|
|
|
|
|
|
@@ -1447,29 +1645,57 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
/// Whether the selected host will want something typed into the password box.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// True with nothing selected, which is deliberate: the box is the resting state of that corner of the
|
|
|
|
|
/// window, and an empty terminal column with no password box in it reads as a column that is still
|
|
|
|
|
/// loading.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The resolved binding, not the host's own two ids. A host that names neither used to mean "type one",
|
|
|
|
|
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
|
|
|
|
|
/// every host under a group that binds a key, and the box would do nothing.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal bool SelectedHostAsksForAPassword =>
|
|
|
|
|
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
|
|
|
|
SelectedHost is null
|
|
|
|
|
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// A sentence rather than a hidden box on its own, because "nothing needs typing" and "something needs
|
|
|
|
|
/// typing and the box has not appeared yet" look identical, and only one of them is fine. Which of the two
|
|
|
|
|
/// bindings is doing it matters to the reader: a stored password can be wrong and re-typed here if this
|
|
|
|
|
/// said nothing, and a key cannot.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>An inherited binding says so.</b> This is the one place with room for the sentence, and it is worth
|
|
|
|
|
/// spending: a user looking at a host that says nothing about keys, being told it authenticates with one,
|
|
|
|
|
/// would go looking in the wrong editor. Naming the group points at the record that can actually be
|
|
|
|
|
/// changed.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
internal string SelectedHostAuthenticationNote => SelectedHost?.Host switch
|
|
|
|
|
internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
|
|
|
|
|
{
|
|
|
|
|
{ CredentialId: not null } => "This host uses a password stored in your keychain.",
|
|
|
|
|
{ SshKeyId: not null } => "This host authenticates with its SSH key.",
|
|
|
|
|
{ Kind: ResolvedBindingKind.Credential } binding =>
|
|
|
|
|
$"This host uses a password stored in your keychain{From(binding)}.",
|
|
|
|
|
{ Kind: ResolvedBindingKind.SshKey } binding =>
|
|
|
|
|
$"This host authenticates with an SSH key{From(binding)}.",
|
|
|
|
|
_ => string.Empty,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to
|
|
|
|
|
/// search. A group that has since been deleted leaves the binding dangling, which the connect path
|
|
|
|
|
/// reports on its own — this only has to avoid claiming a name it cannot read.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private string From(ResolvedBinding binding) =>
|
|
|
|
|
binding.FromGroupId is { } groupId && groupsById.TryGetValue(groupId, out var group)
|
|
|
|
|
? $", from the group {group.Label}"
|
|
|
|
|
: string.Empty;
|
|
|
|
|
|
|
|
|
|
[ObservableProperty]
|
|
|
|
|
private HostKeyPresentation? pendingHostKey;
|
|
|
|
|
|
|
|
|
@@ -1641,8 +1867,10 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
|
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
|
|
|
|
|
|
// Resolved here, which is why ReloadGroupsAsync runs before this: a host resolved against a
|
|
|
|
|
// stale group list would show one port and dial another.
|
|
|
|
|
rows.AddRange(listing.Items.Select(
|
|
|
|
|
item => new HostRowViewModel(item, vault.VaultId, vault.Name)
|
|
|
|
|
item => new HostRowViewModel(item, Resolve(item.Secret), vault.VaultId, vault.Name)
|
|
|
|
|
{
|
|
|
|
|
// Only when there is something to tell apart. A badge on every row of a
|
|
|
|
|
// single-vault list is noise that says the same thing on all of them.
|
|
|
|
@@ -2518,15 +2746,25 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
editingHostVaultId = TargetVaultId;
|
|
|
|
|
EditorLabel = string.Empty;
|
|
|
|
|
EditorHostname = string.Empty;
|
|
|
|
|
EditorPort = HostSecret.DefaultPort;
|
|
|
|
|
|
|
|
|
|
// Empty rather than 22, so a new host under a group that says 2222 is created wanting 2222 without
|
|
|
|
|
// anybody typing it — and one under no group still dials 22, because that is where the chain ends.
|
|
|
|
|
EditorPort = null;
|
|
|
|
|
EditorUsername = string.Empty;
|
|
|
|
|
EditorNotes = string.Empty;
|
|
|
|
|
EditorRelayEnabled = false;
|
|
|
|
|
BuildAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
|
|
|
|
|
editorTagIds = TagSet.Empty;
|
|
|
|
|
|
|
|
|
|
// A new host opens in whichever group is selected beside the list, if one is, because adding three
|
|
|
|
|
// machines to the group somebody has just made is the ordinary case.
|
|
|
|
|
// machines to the group somebody has just made is the ordinary case. Before the picker, because
|
|
|
|
|
// whether there is a group to inherit from decides whether the picker offers to.
|
|
|
|
|
BuildGroupChoices(SelectedGroup?.EntityId);
|
|
|
|
|
|
|
|
|
|
BuildAuthenticationChoices(
|
|
|
|
|
boundKeyId: null,
|
|
|
|
|
boundCredentialId: null,
|
|
|
|
|
asksForPassword: false,
|
|
|
|
|
grouped: EditorSelectedGroup?.EntityId is not null);
|
|
|
|
|
IsEditing = true;
|
|
|
|
|
Status = "Adding a host.";
|
|
|
|
|
}
|
|
|
|
@@ -2552,15 +2790,24 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
editingHostVaultId = row.VaultId;
|
|
|
|
|
EditorLabel = row.Host.Label;
|
|
|
|
|
EditorHostname = row.Host.Hostname;
|
|
|
|
|
// The resolved port rather than the stored one, so a host that inherits opens showing what it
|
|
|
|
|
// actually dials rather than an empty box. The editor cannot yet express "leave this to the group",
|
|
|
|
|
// so saving pins whatever is shown — which is what it did before any of this existed.
|
|
|
|
|
EditorPort = Resolve(row.Host).Port.Value;
|
|
|
|
|
|
|
|
|
|
// The stored port, not the resolved one, and the difference is the whole feature: an inheriting host
|
|
|
|
|
// opens with an empty box showing its group's value as a placeholder. Loading the resolved value
|
|
|
|
|
// instead would fill the box, and saving would then pin what the host had deliberately left open.
|
|
|
|
|
EditorPort = row.Host.Port;
|
|
|
|
|
EditorUsername = row.Host.Username ?? string.Empty;
|
|
|
|
|
EditorNotes = row.Host.Notes ?? string.Empty;
|
|
|
|
|
EditorRelayEnabled = row.Host.RelayEnabled;
|
|
|
|
|
BuildAuthenticationChoices(row.Host.SshKeyId, row.Host.CredentialId);
|
|
|
|
|
editorTagIds = row.Host.TagIds;
|
|
|
|
|
|
|
|
|
|
BuildGroupChoices(row.Host.GroupId);
|
|
|
|
|
|
|
|
|
|
BuildAuthenticationChoices(
|
|
|
|
|
row.Host.SshKeyId,
|
|
|
|
|
row.Host.CredentialId,
|
|
|
|
|
row.Host.AsksForPassword is true,
|
|
|
|
|
grouped: row.Host.GroupId is not null);
|
|
|
|
|
|
|
|
|
|
IsEditing = true;
|
|
|
|
|
Status = $"Editing {row.Label}.";
|
|
|
|
|
}
|
|
|
|
@@ -2645,7 +2892,17 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private async Task SaveGroupAsync(CancellationToken cancellationToken)
|
|
|
|
|
{
|
|
|
|
|
var group = new HostGroupSecret { Label = GroupEditorLabel.Trim() };
|
|
|
|
|
var group = new HostGroupSecret
|
|
|
|
|
{
|
|
|
|
|
Label = GroupEditorLabel.Trim(),
|
|
|
|
|
ParentId = GroupEditorSelectedParent?.EntityId,
|
|
|
|
|
DefaultPort = GroupEditorDefaultPort,
|
|
|
|
|
DefaultUsername = string.IsNullOrWhiteSpace(GroupEditorDefaultUsername)
|
|
|
|
|
? null
|
|
|
|
|
: GroupEditorDefaultUsername.Trim(),
|
|
|
|
|
DefaultSshKeyId = GroupBound(AuthenticationKind.SshKey),
|
|
|
|
|
DefaultCredentialId = GroupBound(AuthenticationKind.Credential),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!group.TryValidate(out var reason))
|
|
|
|
|
{
|
|
|
|
@@ -2672,12 +2929,11 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
.ConfigureAwait(true);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GroupEditorLabel = string.Empty;
|
|
|
|
|
EditingGroupId = null;
|
|
|
|
|
ClearGroupEditor();
|
|
|
|
|
|
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
Status = renaming is null ? $"Added the group '{group.Label}'." : $"Renamed to '{group.Label}'.";
|
|
|
|
|
Status = renaming is null ? $"Added the group '{group.Label}'." : $"Saved '{group.Label}'.";
|
|
|
|
|
}).ConfigureAwait(true);
|
|
|
|
|
|
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
@@ -2700,15 +2956,137 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
|
|
|
|
|
EditingGroupId = row.EntityId;
|
|
|
|
|
GroupEditorLabel = row.Label;
|
|
|
|
|
Status = $"Renaming {row.Label}.";
|
|
|
|
|
GroupEditorDefaultPort = row.Group.DefaultPort;
|
|
|
|
|
GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;
|
|
|
|
|
|
|
|
|
|
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
|
|
|
|
|
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
|
|
|
|
|
|
|
|
|
|
Status = $"Editing {row.Label}.";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>The group picker's selection, if it names something of this kind.</summary>
|
|
|
|
|
private Guid? GroupBound(AuthenticationKind kind) =>
|
|
|
|
|
GroupEditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Fills the parent picker, leaving out the group itself and everything beneath it.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Refusing a descendant here is a convenience, not the guarantee.</b> It stops a cycle being made on
|
|
|
|
|
/// this machine, which is worth doing because the alternative is a user watching their own sidebar go
|
|
|
|
|
/// flat. What it cannot stop is a cycle assembled from two offline re-parents on two machines, neither
|
|
|
|
|
/// of which was ever offered this list — so the walk itself carries a visited set. See
|
|
|
|
|
/// <see cref="HostInheritance"/>.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Descendants are found by walking each candidate <em>upwards</em> rather than this group downwards,
|
|
|
|
|
/// because upwards is the direction the pointer goes and <see cref="HostInheritance.Chain"/> already
|
|
|
|
|
/// terminates on a cycle. Walking down would need a child index this view model does not keep, and
|
|
|
|
|
/// building one that has to survive a cycle is the same problem twice.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
|
|
|
|
|
{
|
|
|
|
|
GroupEditorParentChoices.Clear();
|
|
|
|
|
GroupEditorParentChoices.Add(GroupChoice.None);
|
|
|
|
|
|
|
|
|
|
foreach (var candidate in groupItems.Where(item => item.EntityId != groupId))
|
|
|
|
|
{
|
|
|
|
|
var descends = HostInheritance
|
|
|
|
|
.Chain(candidate.EntityId, groupsById)
|
|
|
|
|
.Any(entry => entry.Id == groupId);
|
|
|
|
|
|
|
|
|
|
if (!descends)
|
|
|
|
|
{
|
|
|
|
|
GroupEditorParentChoices.Add(new GroupChoice(candidate.EntityId, candidate.Secret.Label));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A parent that is no longer selectable keeps a placeholder, so that editing a group's default port
|
|
|
|
|
// cannot unparent it as a side effect — the same reason the host's group picker keeps one.
|
|
|
|
|
if (parentId is { } bound && !GroupEditorParentChoices.Any(choice => choice.EntityId == bound))
|
|
|
|
|
{
|
|
|
|
|
GroupEditorParentChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GroupEditorSelectedParent =
|
|
|
|
|
GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == parentId)
|
|
|
|
|
?? GroupChoice.None;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Fills the group's binding picker, keeping whatever it currently defaults to selectable.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <see cref="BuildAuthenticationChoices"/> without the typed-password entry and without the inherited
|
|
|
|
|
/// one. A group defaults to a key, to a credential, or to nothing — and "nothing" is the sentinel first
|
|
|
|
|
/// entry, because a picker with no selection and a group that deliberately lends no binding look
|
|
|
|
|
/// identical and are not the same thing.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private void BuildGroupAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId)
|
|
|
|
|
{
|
|
|
|
|
GroupEditorAuthenticationChoices.Clear();
|
|
|
|
|
GroupEditorAuthenticationChoices.Add(AuthenticationChoice.NoDefault);
|
|
|
|
|
|
|
|
|
|
foreach (var key in Keys)
|
|
|
|
|
{
|
|
|
|
|
GroupEditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var credential in Credentials)
|
|
|
|
|
{
|
|
|
|
|
GroupEditorAuthenticationChoices.Add(
|
|
|
|
|
AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
AddMissingGroupBinding(AuthenticationKind.SshKey, boundKeyId);
|
|
|
|
|
AddMissingGroupBinding(AuthenticationKind.Credential, boundCredentialId);
|
|
|
|
|
|
|
|
|
|
GroupEditorSelectedAuthentication = (boundKeyId, boundCredentialId) switch
|
|
|
|
|
{
|
|
|
|
|
({ } key, _) => FindGroupBinding(AuthenticationKind.SshKey, key),
|
|
|
|
|
(_, { } credential) => FindGroupBinding(AuthenticationKind.Credential, credential),
|
|
|
|
|
_ => AuthenticationChoice.NoDefault,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void AddMissingGroupBinding(AuthenticationKind kind, Guid? boundId)
|
|
|
|
|
{
|
|
|
|
|
if (boundId is { } bound
|
|
|
|
|
&& !GroupEditorAuthenticationChoices.Any(
|
|
|
|
|
choice => choice.Kind == kind && choice.EntityId == bound))
|
|
|
|
|
{
|
|
|
|
|
GroupEditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private AuthenticationChoice FindGroupBinding(AuthenticationKind kind, Guid entityId) =>
|
|
|
|
|
GroupEditorAuthenticationChoices
|
|
|
|
|
.FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId)
|
|
|
|
|
?? AuthenticationChoice.NoDefault;
|
|
|
|
|
|
|
|
|
|
/// <summary>Empties every box in the group editor, so the next open starts from nothing.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// One method rather than a line per field at each of the three places that clear it. A group editor
|
|
|
|
|
/// left holding the last group's default key would lend it to the next group somebody created without
|
|
|
|
|
/// anybody choosing it, which is the quiet kind of wrong.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private void ClearGroupEditor()
|
|
|
|
|
{
|
|
|
|
|
EditingGroupId = null;
|
|
|
|
|
GroupEditorLabel = string.Empty;
|
|
|
|
|
GroupEditorDefaultPort = null;
|
|
|
|
|
GroupEditorDefaultUsername = string.Empty;
|
|
|
|
|
|
|
|
|
|
BuildGroupParentChoices(Guid.Empty, parentId: null);
|
|
|
|
|
BuildGroupAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Abandons a rename, leaving the box ready to create one instead.</summary>
|
|
|
|
|
[RelayCommand]
|
|
|
|
|
private void CancelGroupEdit()
|
|
|
|
|
{
|
|
|
|
|
EditingGroupId = null;
|
|
|
|
|
GroupEditorLabel = string.Empty;
|
|
|
|
|
ClearGroupEditor();
|
|
|
|
|
Status = string.Empty;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -3193,7 +3571,7 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
$"Delete the SSH key '{row.Label}'?",
|
|
|
|
|
HowFarADeletionGoes("The private key, its passphrase and everything saved with them")
|
|
|
|
|
+ " If this key is not on disk anywhere else, this is the only copy.",
|
|
|
|
|
HostsBoundTo(host => host.SshKeyId, row.EntityId));
|
|
|
|
|
HostsBoundTo(ResolvedBindingKind.SshKey, row.EntityId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Queues a tombstone for the key that was agreed to.</summary>
|
|
|
|
@@ -3523,7 +3901,7 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
row.EntityId,
|
|
|
|
|
$"Delete the password '{row.Label}'?",
|
|
|
|
|
HowFarADeletionGoes("The password and the account saved with it"),
|
|
|
|
|
HostsBoundTo(host => host.CredentialId, row.EntityId));
|
|
|
|
|
HostsBoundTo(ResolvedBindingKind.Credential, row.EntityId));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>Queues a tombstone for the credential that was agreed to.</summary>
|
|
|
|
@@ -3614,15 +3992,25 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
/// What the hosts that authenticate with an item would be left with, or nothing when none do.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Counted rather than warned about in general terms. The number is the difference between a sentence
|
|
|
|
|
/// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound
|
|
|
|
|
/// to something the vault no longer has is refused at connect time rather than quietly falling back to a
|
|
|
|
|
/// typed password — see <see cref="TryBuildAuthentication" />.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Counted over the resolved binding, so a group's default is counted too.</b> Reading each host's own
|
|
|
|
|
/// ids would miss a key that only a group names — which is the worst case rather than an edge one: a key
|
|
|
|
|
/// bound once on a group and inherited by twenty hosts would warn about nobody, be deleted, and then
|
|
|
|
|
/// refuse all twenty at connect time.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private string HostsBoundTo(Func<HostSecret, Guid?> binding, Guid entityId)
|
|
|
|
|
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
|
|
|
|
|
{
|
|
|
|
|
var bound = Hosts
|
|
|
|
|
.Where(row => binding(row.Host) == entityId)
|
|
|
|
|
.Where(row => row.Resolved.Binding is { } binding
|
|
|
|
|
&& binding.Kind == kind
|
|
|
|
|
&& binding.EntityId == entityId)
|
|
|
|
|
.Select(row => row.Label)
|
|
|
|
|
.ToArray();
|
|
|
|
|
|
|
|
|
@@ -3742,7 +4130,8 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
|
|
|
|
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
|
|
|
|
// thing and got another, and the host is the last place that would say so.
|
|
|
|
|
if (!TryBuildAuthentication(row.Host, ConnectPassword, out var authentication, out var refusal))
|
|
|
|
|
if (!TryBuildAuthentication(
|
|
|
|
|
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
|
|
|
|
|
{
|
|
|
|
|
Status = refusal;
|
|
|
|
|
return;
|
|
|
|
@@ -4072,14 +4461,16 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
|
|
|
|
|
/// <summary>The address as actually dialled.</summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// Built from what was dialled rather than from the host's own fields, because a bound credential can
|
|
|
|
|
/// supply the username — so a host saved with no username of its own still has one here, and it is the
|
|
|
|
|
/// one the remote saw.
|
|
|
|
|
/// Built from what was dialled rather than from the host's own fields, because neither half of it need
|
|
|
|
|
/// come from the host. A bound credential can supply the username, and a group can supply the port and
|
|
|
|
|
/// the username both — so a host saved with neither still has both here, and they are the ones the
|
|
|
|
|
/// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log
|
|
|
|
|
/// naming a port nothing dialled is worse than no log.
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private static string Dialled(HostRowViewModel row, HostAuthentication authentication) =>
|
|
|
|
|
string.Create(
|
|
|
|
|
CultureInfo.InvariantCulture,
|
|
|
|
|
$"{authentication.Username}@{row.Host.Hostname}:{row.Host.Port}");
|
|
|
|
|
$"{authentication.Username}@{row.Host.Hostname}:{row.Resolved.Port.Value}");
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
|
|
|
|
@@ -4201,7 +4592,11 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
[NotNullWhen(true)] out SshConnectionRequest? request,
|
|
|
|
|
[NotNullWhen(false)] out string? reason)
|
|
|
|
|
{
|
|
|
|
|
if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason))
|
|
|
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
|
|
|
|
|
|
|
|
var resolved = Resolve(host);
|
|
|
|
|
|
|
|
|
|
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
|
|
|
|
{
|
|
|
|
|
request = null;
|
|
|
|
|
return false;
|
|
|
|
@@ -4209,53 +4604,77 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
|
|
|
|
|
request = new SshConnectionRequest(
|
|
|
|
|
host.Hostname,
|
|
|
|
|
Resolve(host).Port.Value,
|
|
|
|
|
resolved.Port.Value,
|
|
|
|
|
authentication.Username,
|
|
|
|
|
authentication.Credential);
|
|
|
|
|
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// Turns a host into a key, a password or a prompt, or says why it cannot.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Takes the resolved host as well as the stored one, and this is where group context used to be
|
|
|
|
|
/// lost.</b> Both callers of this and of <see cref="TryBuildConnectionRequest"/> used to hand over a
|
|
|
|
|
/// bare <see cref="HostSecret"/>, which answers "what did the user type into this host" rather than
|
|
|
|
|
/// "what happens when it is connected". It is the only authentication resolution in the product — both
|
|
|
|
|
/// heads and both transports come through here — so a host inheriting its binding would otherwise have
|
|
|
|
|
/// been offered a password prompt on every screen at once.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The refusal messages name the group when the binding came from one. A user told that "this host
|
|
|
|
|
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
|
|
|
|
|
/// nothing about keys.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private bool TryBuildAuthentication(
|
|
|
|
|
HostSecret host,
|
|
|
|
|
ResolvedHost resolved,
|
|
|
|
|
string typedPassword,
|
|
|
|
|
[NotNullWhen(true)] out HostAuthentication? authentication,
|
|
|
|
|
[NotNullWhen(false)] out string? reason)
|
|
|
|
|
{
|
|
|
|
|
if (host.CredentialId is { } credentialId)
|
|
|
|
|
var binding = resolved.Binding;
|
|
|
|
|
var where = binding.IsInherited ? $"'{host.Label}' inherits" : $"'{host.Label}' authenticates";
|
|
|
|
|
var repair = binding.IsInherited
|
|
|
|
|
? "Edit the group it is filed under, or bind the host itself."
|
|
|
|
|
: "Edit the host to choose another one, or set it back to a typed password.";
|
|
|
|
|
|
|
|
|
|
if (binding is { Kind: ResolvedBindingKind.Credential, EntityId: { } credentialId })
|
|
|
|
|
{
|
|
|
|
|
if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential)
|
|
|
|
|
{
|
|
|
|
|
return Refuse(
|
|
|
|
|
$"'{host.Label}' authenticates with a credential that is not in this keychain any more. "
|
|
|
|
|
+ "Edit the host to choose another one, or set it back to a typed password.",
|
|
|
|
|
$"{where} a credential that is not in this keychain any more. {repair}",
|
|
|
|
|
out authentication,
|
|
|
|
|
out reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The credential's username wins where it has one, which is the whole reason it can carry one: one
|
|
|
|
|
// account on twenty machines is described once. Falling back to the host's covers the ordinary
|
|
|
|
|
// case of a shared password used under each machine's own account.
|
|
|
|
|
// account on twenty machines is described once. Falling back to the resolved username covers the
|
|
|
|
|
// ordinary case of a shared password used under each machine's own account — and it is the
|
|
|
|
|
// resolved one rather than the host's, so the fallback has three levels rather than two.
|
|
|
|
|
return Complete(
|
|
|
|
|
credential.Credential.Username ?? host.Username,
|
|
|
|
|
credential.Credential.Username ?? resolved.Username.Value,
|
|
|
|
|
new SshPasswordCredential(credential.Credential.Password),
|
|
|
|
|
out authentication,
|
|
|
|
|
out reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (host.SshKeyId is { } keyId)
|
|
|
|
|
if (binding is { Kind: ResolvedBindingKind.SshKey, EntityId: { } keyId })
|
|
|
|
|
{
|
|
|
|
|
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
|
|
|
|
|
{
|
|
|
|
|
return Refuse(
|
|
|
|
|
$"'{host.Label}' authenticates with an SSH key that is not in this keychain any more. "
|
|
|
|
|
+ "Edit the host to choose another key, or set it back to a password.",
|
|
|
|
|
$"{where} an SSH key that is not in this keychain any more. {repair}",
|
|
|
|
|
out authentication,
|
|
|
|
|
out reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Complete(
|
|
|
|
|
host.Username,
|
|
|
|
|
resolved.Username.Value,
|
|
|
|
|
new SshPrivateKeyCredential(
|
|
|
|
|
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
|
|
|
|
out authentication,
|
|
|
|
@@ -4263,17 +4682,27 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Complete(
|
|
|
|
|
host.Username, new SshPasswordCredential(typedPassword), out authentication, out reason);
|
|
|
|
|
resolved.Username.Value,
|
|
|
|
|
new SshPasswordCredential(typedPassword),
|
|
|
|
|
out authentication,
|
|
|
|
|
out reason);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <summary>
|
|
|
|
|
/// The last thing every branch has to agree on: there is somebody to log in as.
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Checked here rather than at the top of <see cref="TryBuildAuthentication" /> because the answer depends
|
|
|
|
|
/// on which branch was taken — a host with no username of its own is perfectly usable through a credential
|
|
|
|
|
/// that carries one, and refusing it up front would have made the credential's most useful property
|
|
|
|
|
/// unreachable.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// It is also why every caller passes a username the group chain has already been consulted for. Refusing
|
|
|
|
|
/// before the chain is walked would refuse exactly the hosts inheritance exists to serve: the twenty
|
|
|
|
|
/// machines filed under one group that says <c>deploy</c> once.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private static bool Complete(
|
|
|
|
|
string? username,
|
|
|
|
@@ -4309,11 +4738,33 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
{
|
|
|
|
|
Label = EditorLabel.Trim(),
|
|
|
|
|
Hostname = EditorHostname.Trim(),
|
|
|
|
|
Port = EditorPort,
|
|
|
|
|
|
|
|
|
|
// Empty means "take the group's" in both directions, which is what makes the placeholder honest:
|
|
|
|
|
// what the box showed while empty is what the host will use. A relay host is the exception —
|
|
|
|
|
// HostSecret.TryValidate refuses one with no port of its own, so the box is pre-filled and
|
|
|
|
|
// required there; see EditorRelayEnabled.
|
|
|
|
|
Port = EditorRelayEnabled ? EditorPort ?? HostSecret.DefaultPort : EditorPort,
|
|
|
|
|
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
|
|
|
|
|
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
|
|
|
|
RelayEnabled = EditorRelayEnabled,
|
|
|
|
|
|
|
|
|
|
// Only ever true or null, never false: the two would mean the same thing, and writing false would
|
|
|
|
|
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
|
|
|
|
|
//
|
|
|
|
|
// And only for a host in a group, because only then was there another entry to choose instead —
|
|
|
|
|
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
|
|
|
|
|
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
|
|
|
|
|
// host in the vault the first time it was edited.
|
|
|
|
|
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
|
|
|
|
&& EditorSelectedGroup?.EntityId is not null
|
|
|
|
|
? true
|
|
|
|
|
: null,
|
|
|
|
|
|
|
|
|
|
// Carried through rather than edited here. Nothing on this head can put a tag on a host yet, and
|
|
|
|
|
// rebuilding the host from the editor's boxes alone would strip the tags a teammate had added on
|
|
|
|
|
// a machine that can. See docs/adding-hosts-on-the-phone.md.
|
|
|
|
|
TagIds = editorTagIds,
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
@@ -4336,17 +4787,36 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
/// </summary>
|
|
|
|
|
/// <param name="boundKeyId">The key the host names, if any.</param>
|
|
|
|
|
/// <param name="boundCredentialId">The credential the host names, if any.</param>
|
|
|
|
|
/// <param name="asksForPassword">Whether the host is pinned to a typed password.</param>
|
|
|
|
|
/// <param name="grouped">Whether the host is filed under a group, and so has anything to inherit.</param>
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// A binding whose target is no longer in the vault gets a placeholder entry rather than being dropped.
|
|
|
|
|
/// Without one the picker would open on "Password (ask each time)", and someone editing the host's port
|
|
|
|
|
/// would convert it to a typed password by saving — which is the quiet version of the failure the connect
|
|
|
|
|
/// path refuses outright.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// <b>Four entries where there were three, and only for a host in a group.</b> The three states two
|
|
|
|
|
/// nullable ids could carry became four when naming neither came to mean "inherit"; see
|
|
|
|
|
/// <see cref="AuthenticationKind.Inherited"/>. For an ungrouped host the fourth would behave exactly like
|
|
|
|
|
/// the first, so it is left out rather than offered and then explained.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private void BuildAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId)
|
|
|
|
|
private void BuildAuthenticationChoices(
|
|
|
|
|
Guid? boundKeyId,
|
|
|
|
|
Guid? boundCredentialId,
|
|
|
|
|
bool asksForPassword,
|
|
|
|
|
bool grouped)
|
|
|
|
|
{
|
|
|
|
|
EditorAuthenticationChoices.Clear();
|
|
|
|
|
EditorAuthenticationChoices.Add(AuthenticationChoice.Typed);
|
|
|
|
|
|
|
|
|
|
if (grouped)
|
|
|
|
|
{
|
|
|
|
|
EditorAuthenticationChoices.Add(AuthenticationChoice.Inherited);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach (var key in Keys)
|
|
|
|
|
{
|
|
|
|
|
EditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label));
|
|
|
|
@@ -4362,7 +4832,8 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
AddMissing(AuthenticationKind.SshKey, boundKeyId);
|
|
|
|
|
AddMissing(AuthenticationKind.Credential, boundCredentialId);
|
|
|
|
|
|
|
|
|
|
EditorSelectedAuthentication = Selected(boundKeyId, boundCredentialId);
|
|
|
|
|
EditorSelectedAuthentication =
|
|
|
|
|
Selected(boundKeyId, boundCredentialId, asksForPassword, grouped);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void AddMissing(AuthenticationKind kind, Guid? boundId)
|
|
|
|
@@ -4375,16 +4846,28 @@ internal sealed partial class VaultViewModel(
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// <remarks>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// Matched on the kind as well as the id. Ids are v7 GUIDs and a collision is not the worry — selecting the
|
|
|
|
|
/// right row for the wrong reason is, because a lookup by id alone would compile, pass, and silently pick a
|
|
|
|
|
/// key when the host named a credential the day the two ever shared an id.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// <para>
|
|
|
|
|
/// The last two arms are where the third state has to be told from the fourth. A host that names neither
|
|
|
|
|
/// binding and does not ask for a password is inheriting; one that asks is not. An ungrouped host has no
|
|
|
|
|
/// "Inherit" entry to select, so it falls to the typed one — which is what it resolves to anyway.
|
|
|
|
|
/// </para>
|
|
|
|
|
/// </remarks>
|
|
|
|
|
private AuthenticationChoice Selected(Guid? boundKeyId, Guid? boundCredentialId) =>
|
|
|
|
|
private AuthenticationChoice Selected(
|
|
|
|
|
Guid? boundKeyId,
|
|
|
|
|
Guid? boundCredentialId,
|
|
|
|
|
bool asksForPassword,
|
|
|
|
|
bool grouped) =>
|
|
|
|
|
(boundKeyId, boundCredentialId) switch
|
|
|
|
|
{
|
|
|
|
|
({ } key, _) => Find(AuthenticationKind.SshKey, key),
|
|
|
|
|
(_, { } credential) => Find(AuthenticationKind.Credential, credential),
|
|
|
|
|
_ => AuthenticationChoice.Typed,
|
|
|
|
|
_ when asksForPassword || !grouped => AuthenticationChoice.Typed,
|
|
|
|
|
_ => AuthenticationChoice.Inherited,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
private AuthenticationChoice Find(AuthenticationKind kind, Guid entityId) =>
|
|
|
|
|