Public Access
The vault has held credentials and host bindings since they landed, and the only route from a typed password into one ran through two screens: add a password under Keychain, open the host, bind it. The password box's own tooltip instructed people to do exactly that by hand — which means typing the secret a second time, into a screen that has no idea which host it is for, while the screen that does have the password is the one being left behind. A tick beside the box does it in one step. What it produces is an ordinary CredentialSecret, named after the host, bound through CredentialId with AsksForPassword cleared beside it. That is the whole reason nothing else here had to change: it syncs, it merges, it appears in the keychain, it can be renamed and deleted, and it can be bound to the other nineteen machines that share the account. A password field on HostSecret would have been a payload schema bump, a fourth place a secret lives, and a copy per host to rotate and forget. It waits for a handshake the remote accepted, and that is not caution for its own sake. Binding on the keystroke would store whatever was in the box — including the typo about to be refused — and the host would then stop asking, leaving a machine nobody can connect to until they work out that the keychain is where the wrong password now lives. For the same reason the password is read from the credential as dialled rather than from the box, which stays typeable throughout a handshake. Off by default, because the typed box exists precisely for the passwords that should not be in a synchronised vault — a one-off on a machine somebody will never open again. The credential is written into the host's own vault rather than the active one: in the personal vault, bound to a team's host, it would be a binding every other member can see and none of them can resolve. One thing is given up knowingly. The confirmation naming where the password went is replaced a moment later by the auto-sync's own count, exactly as SaveHostAsync's "Saved 'x'" is; this follows that rather than becoming the one write that suppresses a sync line. The feedback that lasts is the row answering "credential" and the box disappearing — which is also the only way to store a password on the phone at all, since that head lists credentials but has never had an editor to create one in.
6385 lines
272 KiB
C#
6385 lines
272 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Diagnostics.CodeAnalysis;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Api;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Session;
|
|
using DodoSSH.Client.Ssh;
|
|
using DodoSSH.Client.Sync;
|
|
using DodoSSH.Client.Terminal;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>
|
|
/// Anything the host sidebar's one list can hold.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A marker, because the list is one <c>ListBox</c> and has to stay one — it owns the selection, and it is
|
|
/// where keyboard focus lands when the terminal gives it back. Neither survives being split into a list per
|
|
/// group, which is why headings are rows rather than containers.
|
|
/// </para>
|
|
/// <para>
|
|
/// The cost is that a heading is selectable as far as the <c>ListBox</c> is concerned, and it must not be as
|
|
/// far as anything else is: <c>Connect</c>, <c>Edit</c> and <c>Delete</c> all read the host selection. See
|
|
/// <c>VaultViewModel.SelectedSidebarRow</c>, which is where a heading click is turned back into whatever was
|
|
/// selected before it.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal interface ISidebarRow;
|
|
|
|
/// <summary>One group heading, as a row in the host list.</summary>
|
|
/// <param name="GroupId">The group, or null for the heading ungrouped hosts fall under.</param>
|
|
/// <param name="Label">What the heading says.</param>
|
|
/// <param name="Count">How many hosts are under it, after the filter.</param>
|
|
/// <param name="IsExpanded">Whether its hosts are showing.</param>
|
|
internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded)
|
|
: ISidebarRow
|
|
{
|
|
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
|
internal string Chevron => IsExpanded ? "▾" : "▸";
|
|
}
|
|
|
|
/// <summary>One group, as a row in the group list.</summary>
|
|
/// <remarks>
|
|
/// Thinner than the other row types because a group is thinner: a name, and how many hosts name it. The
|
|
/// count is computed from the host list rather than stored on the group — see <see cref="HostGroupSecret"/>
|
|
/// for why membership lives on the host — so it is passed in rather than read off the item.
|
|
/// </remarks>
|
|
internal sealed class HostGroupRowViewModel(VaultItem<HostGroupSecret> group, int hostCount)
|
|
{
|
|
internal Guid EntityId => group.EntityId;
|
|
|
|
internal HostGroupSecret Group => group.Secret;
|
|
|
|
internal string Label => group.Secret.Label;
|
|
|
|
internal int HostCount => hostCount;
|
|
|
|
internal bool IsReadOnly => group.IsReadOnly;
|
|
|
|
internal string Badge => ItemBadge.For(group.IsBlocked, group.IsReadOnly, group.HasUnsyncedChanges);
|
|
|
|
/// <summary>What the row says under the name.</summary>
|
|
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
|
}
|
|
|
|
/// <summary>An entry in the host editor's group picker.</summary>
|
|
/// <param name="EntityId">The group, or null for "no group".</param>
|
|
/// <param name="Label">What to show.</param>
|
|
/// <remarks>
|
|
/// A sentinel entry rather than a nullable selection, for the reason <see cref="AuthenticationChoice"/> gives:
|
|
/// a ComboBox with nothing selected and a ComboBox meaning "no group" look identical and are not the same
|
|
/// thing. As there, a group the vault no longer has keeps a placeholder entry, so that editing a host's port
|
|
/// cannot quietly unfile it.
|
|
/// </remarks>
|
|
internal sealed record GroupChoice(Guid? EntityId, string Label)
|
|
{
|
|
/// <summary>The "not in a group" entry, always first.</summary>
|
|
internal static GroupChoice None { get; } = new(null, "No group");
|
|
}
|
|
|
|
/// <summary>One host, and the group it is being filed under.</summary>
|
|
/// <param name="Host">The host to move.</param>
|
|
/// <param name="GroupId">The group it should end up in, or null for none.</param>
|
|
/// <remarks>
|
|
/// A pair rather than two command parameters, because a command takes one — and a pair rather than the two
|
|
/// ids, because the host row is what the caller is holding: it is the thing that was dragged, and it already
|
|
/// carries the vault the edit has to return to.
|
|
/// </remarks>
|
|
internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
|
|
|
|
/// <summary>One snippet, as a row in the list.</summary>
|
|
/// <remarks>
|
|
/// Carries the decrypted <see cref="SnippetSecret"/> so opening the editor needs no second decryption, in
|
|
/// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
|
|
/// per click.
|
|
/// </remarks>
|
|
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
|
{
|
|
internal Guid EntityId => snippet.EntityId;
|
|
|
|
internal SnippetSecret Snippet => snippet.Secret;
|
|
|
|
internal string Label => snippet.Secret.Label;
|
|
|
|
internal bool RunsOnInsert => snippet.Secret.RunsOnInsert;
|
|
|
|
internal bool IsReadOnly => snippet.IsReadOnly;
|
|
|
|
internal bool HasUnsyncedChanges => snippet.HasUnsyncedChanges;
|
|
|
|
internal string Badge => ItemBadge.For(snippet.IsBlocked, snippet.IsReadOnly, snippet.HasUnsyncedChanges);
|
|
|
|
/// <summary>
|
|
/// The command, on one line, for the list.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Newlines become <c>⏎</c> rather than being dropped or wrapped. A three-line snippet shown as one run
|
|
/// of text would read as a single command, which is exactly the thing the user is deciding about when
|
|
/// they look at this row.
|
|
/// </remarks>
|
|
internal string Preview => snippet.Secret.Command
|
|
.ReplaceLineEndings("\n")
|
|
.Replace("\n", " ⏎ ", StringComparison.Ordinal)
|
|
.Trim();
|
|
}
|
|
|
|
/// <summary>One tag, as a row in the keychain table.</summary>
|
|
/// <remarks>
|
|
/// The narrowest row on that screen — a tag is a name — and it is there rather than only inside the host
|
|
/// editor for the reason the type exists at all: a tag is worth being an item because renaming it is one
|
|
/// write instead of twenty, and a rename needs somewhere to happen. Deleting needs somewhere too, or the
|
|
/// picker fills with names nobody uses and never empties.
|
|
/// </remarks>
|
|
internal sealed class TagRowViewModel(VaultItem<TagSecret> tag, int hostCount)
|
|
{
|
|
internal Guid EntityId => tag.EntityId;
|
|
|
|
internal TagSecret Tag => tag.Secret;
|
|
|
|
internal string Label => tag.Secret.Label;
|
|
|
|
internal int HostCount => hostCount;
|
|
|
|
internal bool IsReadOnly => tag.IsReadOnly;
|
|
|
|
internal bool HasUnsyncedChanges => tag.HasUnsyncedChanges;
|
|
|
|
internal string Badge => ItemBadge.For(tag.IsBlocked, tag.IsReadOnly, tag.HasUnsyncedChanges);
|
|
|
|
/// <summary>What the row says under the name.</summary>
|
|
/// <remarks>
|
|
/// The count, because it is the only fact a tag has beyond its name and it is the one that decides
|
|
/// whether deleting it matters. A tag on nothing is a tidy-up; a tag on twenty machines is a filter
|
|
/// somebody relies on.
|
|
/// </remarks>
|
|
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
|
}
|
|
|
|
/// <summary>
|
|
/// One tag in the host editor's picker, and whether this host wears it.
|
|
/// </summary>
|
|
/// <param name="EntityId">The tag item.</param>
|
|
/// <param name="Label">What to show on the chip.</param>
|
|
/// <param name="IsWorn">Whether the host being edited currently carries it.</param>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A chip that toggles rather than a multi-select list, because that is what a tag looks like everywhere
|
|
/// else on both heads — the row already draws worn tags as chips, and a picker drawn as anything else
|
|
/// would make the user match a list entry to a chip they can see two inches away.
|
|
/// </para>
|
|
/// <para>
|
|
/// Rebuilt whenever the set changes rather than mutated, so the chip is a value and equality is contents.
|
|
/// A mutable <c>IsWorn</c> would need change notification on every chip in the vault to make one toggle
|
|
/// redraw.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed record TagChoice(Guid EntityId, string Label, bool IsWorn);
|
|
|
|
/// <summary>One host, as a row in the list.</summary>
|
|
/// <remarks>
|
|
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
|
|
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
|
|
/// an item a newer client wrote that must not be re-encoded here.
|
|
/// </remarks>
|
|
internal sealed partial class HostRowViewModel(
|
|
VaultItem<HostSecret> host,
|
|
ResolvedHost resolved,
|
|
IReadOnlyList<string> tagLabels,
|
|
Guid vaultId,
|
|
string vaultName) : ObservableObject, ISidebarRow
|
|
{
|
|
internal Guid EntityId => host.EntityId;
|
|
|
|
/// <summary>
|
|
/// Which vault this host lives in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Carried on the row rather than read from the session, because a session now holds several and an
|
|
/// edit has to return to the vault the item came from. Writing it to the active vault instead would
|
|
/// create a second copy in the personal vault and leave the team's original untouched — a silent fork
|
|
/// that only shows up when somebody else wonders why their change never arrived.
|
|
/// </remarks>
|
|
internal Guid VaultId => vaultId;
|
|
|
|
/// <summary>The vault's display name, for the heading the sidebar groups under.</summary>
|
|
internal string VaultName => vaultName;
|
|
|
|
/// <summary>
|
|
/// The vault name to print on this row, or empty when there is only one vault to be in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Decided by the list rather than by the row, because "is there more than one vault" is not
|
|
/// something a row can see — and the alternative, a binding that reaches out to the parent view
|
|
/// model from inside an item template, is the kind of thing that silently resolves to nothing.
|
|
/// </remarks>
|
|
internal string VaultBadge { get; init; } = string.Empty;
|
|
|
|
/// <summary>Whether this row has a vault to name.</summary>
|
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
|
|
|
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;
|
|
|
|
/// <summary>
|
|
/// The names of the tags this host wears, in the order the chips are drawn.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Names rather than ids, resolved once when the list is built, because a chip shows a name and the
|
|
/// host stores an id — and resolving per chip per redraw would be a dictionary lookup per tag per row
|
|
/// per frame for a string that cannot change without the list being rebuilt.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A tag that does not resolve is left out rather than drawn as its id.</b> It means the tag was
|
|
/// deleted on another machine, or belongs to a vault this session cannot read; either way the honest
|
|
/// answer is a host with one chip fewer, not one wearing a GUID. That the host still carries the id is
|
|
/// the point — nothing rewrites twenty payloads to clear one deleted tag, so the chip comes back if the
|
|
/// tag does. See <see cref="HostSecret.TagIds"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal IReadOnlyList<string> TagLabels => tagLabels;
|
|
|
|
/// <summary>Whether this host has any chips to draw.</summary>
|
|
internal bool HasTags => tagLabels.Count > 0;
|
|
|
|
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,
|
|
$"{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;
|
|
|
|
internal bool IsBlocked => host.IsBlocked;
|
|
|
|
internal bool IsReadOnly => host.IsReadOnly;
|
|
|
|
/// <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 => resolved.Binding.Kind switch
|
|
{
|
|
ResolvedBindingKind.Credential => "credential",
|
|
ResolvedBindingKind.SshKey => "key",
|
|
_ => "password",
|
|
};
|
|
|
|
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
|
|
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
|
|
|
|
/// <summary>
|
|
/// Whether a terminal is open on this host right now.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one thing on a host row that is not a property of the host. It is written by the shell, which owns
|
|
/// the tab list, because a session outlives the vault that opened it — so the vault cannot be the one
|
|
/// holding the answer. The design's status dot is this, and it is the reason the row is observable at
|
|
/// all: everything else here is fixed for the row's lifetime and a reload replaces the row outright.
|
|
/// </para>
|
|
/// <para>
|
|
/// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability
|
|
/// would be a claim this application never checks.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isConnected;
|
|
}
|
|
|
|
/// <summary>What a host can authenticate with.</summary>
|
|
internal enum AuthenticationKind
|
|
{
|
|
/// <summary>Typed at the moment of connecting.</summary>
|
|
/// <remarks>
|
|
/// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it
|
|
/// creates a credential and moves the host to <see cref="Credential"/>, so a stored password is always
|
|
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
|
|
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
|
|
/// </remarks>
|
|
Typed,
|
|
|
|
/// <summary>An SSH key in this vault.</summary>
|
|
SshKey,
|
|
|
|
/// <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>
|
|
/// <param name="Kind">Which of the three ways this entry means.</param>
|
|
/// <param name="EntityId">The bound item's id, or null for a typed password.</param>
|
|
/// <param name="Label">What to show.</param>
|
|
/// <param name="Qualifier">
|
|
/// What kind of thing the label names, shown beside it. Empty for the typed-password entry, which is not a
|
|
/// thing in the vault.
|
|
/// </param>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>One picker for all three, not two pickers.</b> A host authenticates with a key <i>or</i> a stored
|
|
/// credential <i>or</i> a typed password, never two of them — <c>HostSecret.TryValidate</c> refuses a host
|
|
/// naming both. Two controls would express the illegal state and then reject it at save time; one control
|
|
/// cannot express it at all. That is the same reason this is a sentinel entry rather than a nullable
|
|
/// selection: a ComboBox with nothing selected and a ComboBox meaning "type a password" look identical and
|
|
/// are not the same thing — the first is a host whose binding has not been decided, the second is a decision.
|
|
/// </para>
|
|
/// <para>
|
|
/// The qualifier is load-bearing rather than decoration. Keys and credentials are named by the user and often
|
|
/// named the same thing — a key called <c>deploy</c> and the deploy account's password are the ordinary case —
|
|
/// so a list of bare labels would offer two indistinguishable entries that authenticate completely
|
|
/// differently.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed record AuthenticationChoice(
|
|
AuthenticationKind Kind,
|
|
Guid? EntityId,
|
|
string Label,
|
|
string Qualifier)
|
|
{
|
|
/// <summary>The "type it each time" entry, always first.</summary>
|
|
/// <remarks>
|
|
/// Named for what it costs rather than for what it lacks. "Password (no key)" described the old two-way
|
|
/// choice from the key's side; with credentials in the same list the distinction a user needs is between a
|
|
/// password this vault knows and one they will be asked for.
|
|
/// </remarks>
|
|
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");
|
|
|
|
/// <summary>A credential that is in the vault.</summary>
|
|
internal static AuthenticationChoice ForCredential(Guid entityId, string label) =>
|
|
new(AuthenticationKind.Credential, entityId, label, "credential");
|
|
|
|
/// <summary>
|
|
/// A stand-in for something the host names and the vault no longer has.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Kept in the list, and kept selected, so that opening a host to change its port does not silently
|
|
/// convert it to a typed password on save. The id and the kind are both preserved — the kind because
|
|
/// dropping it would rebind a dangling credential as a dangling key — and only the label admits the
|
|
/// problem.
|
|
/// </remarks>
|
|
internal static AuthenticationChoice Missing(AuthenticationKind kind, Guid entityId) => new(
|
|
kind,
|
|
entityId,
|
|
kind is AuthenticationKind.Credential
|
|
? "(a credential that is no longer here)"
|
|
: "(a key that is no longer here)",
|
|
kind is AuthenticationKind.Credential ? "credential" : "SSH key");
|
|
}
|
|
|
|
/// <summary>One SSH key, as a row in the list.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Carries the decrypted <see cref="SshKeySecret"/>, as the host row carries its host, so opening the
|
|
/// editor or connecting with the key needs no second decryption.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Nothing here exposes the private key to the view.</b> <see cref="Key"/> is what the editor and the
|
|
/// connect path read, and the members the XAML binds are the label, a description and a badge. That is not
|
|
/// a security boundary — the same object holds the material either way — but it does mean no template,
|
|
/// tooltip or accessibility surface can end up rendering a private key by being pointed at the obvious
|
|
/// property.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vaultId, string vaultName)
|
|
{
|
|
internal Guid EntityId => key.EntityId;
|
|
|
|
/// <summary>Which vault this key lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
|
internal Guid VaultId => vaultId;
|
|
|
|
/// <summary>The vault's display name.</summary>
|
|
internal string VaultName => vaultName;
|
|
|
|
internal SshKeySecret Key => key.Secret;
|
|
|
|
internal string Label => key.Secret.Label;
|
|
|
|
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
|
|
internal string Description => key.Secret switch
|
|
{
|
|
{ Passphrase: not null, PublicKey: not null } => "passphrase · public half stored",
|
|
{ Passphrase: not null } => "passphrase · no public half",
|
|
{ PublicKey: not null } => "no passphrase · public half stored",
|
|
_ => "no passphrase · no public half",
|
|
};
|
|
|
|
internal bool HasUnsyncedChanges => key.HasUnsyncedChanges;
|
|
|
|
internal bool IsBlocked => key.IsBlocked;
|
|
|
|
internal bool IsReadOnly => key.IsReadOnly;
|
|
|
|
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
|
|
}
|
|
|
|
/// <summary>One stored credential, as a row in the list.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The same arrangement as <see cref="SshKeyRowViewModel"/>, for the same two reasons: the decrypted secret
|
|
/// travels with the row so opening the editor or connecting needs no second decryption, and
|
|
/// <b>nothing here exposes the password to the view</b>. <see cref="Credential"/> is what the editor and the
|
|
/// connect path read; what the XAML binds is a label, a description and a badge. Not a security boundary —
|
|
/// the same object holds the password either way — but it means no template, tooltip or accessibility surface
|
|
/// can render a password by being pointed at the obvious property.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <summary>One bucket, as a row in the list.</summary>
|
|
internal sealed class ObjectStoreRowViewModel(VaultItem<ObjectStoreSecret> store)
|
|
{
|
|
internal Guid EntityId => store.EntityId;
|
|
|
|
internal ObjectStoreSecret Store => store.Secret;
|
|
|
|
internal string Label => store.Secret.Label;
|
|
|
|
/// <summary>What the list shows under the name: where it is, never the keys.</summary>
|
|
/// <remarks>
|
|
/// The bucket and the service, because those are what tell two entries apart — the same bucket name in
|
|
/// two accounts is the ordinary case. The access key id is an identifier rather than a secret and is
|
|
/// still not here: it is long, it is noise in a list, and it belongs in the detail pane.
|
|
/// </remarks>
|
|
internal string Description => store.Secret.Endpoint is { } endpoint
|
|
? $"{store.Secret.Bucket} at {endpoint}"
|
|
: $"{store.Secret.Bucket} · {store.Secret.Region}";
|
|
|
|
internal bool HasUnsyncedChanges => store.HasUnsyncedChanges;
|
|
|
|
internal bool IsBlocked => store.IsBlocked;
|
|
|
|
internal bool IsReadOnly => store.IsReadOnly;
|
|
|
|
internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges);
|
|
}
|
|
|
|
internal sealed class CredentialRowViewModel(
|
|
VaultItem<CredentialSecret> credential,
|
|
Guid vaultId,
|
|
string vaultName)
|
|
{
|
|
internal Guid EntityId => credential.EntityId;
|
|
|
|
/// <summary>Which vault this credential lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
|
internal Guid VaultId => vaultId;
|
|
|
|
/// <summary>The vault's display name.</summary>
|
|
internal string VaultName => vaultName;
|
|
|
|
internal CredentialSecret Credential => credential.Secret;
|
|
|
|
internal string Label => credential.Secret.Label;
|
|
|
|
/// <summary>What the list shows under the name: the account, never the password.</summary>
|
|
/// <remarks>
|
|
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
|
|
/// it is what the row has to show. Absent means "whatever the host says", which is a different statement
|
|
/// from a blank one and is written out rather than left as an empty line.
|
|
/// </remarks>
|
|
internal string Description => credential.Secret.Username is { } username
|
|
? username
|
|
: "uses each host's own username";
|
|
|
|
internal bool HasUnsyncedChanges => credential.HasUnsyncedChanges;
|
|
|
|
internal bool IsBlocked => credential.IsBlocked;
|
|
|
|
internal bool IsReadOnly => credential.IsReadOnly;
|
|
|
|
internal string Badge =>
|
|
ItemBadge.For(credential.IsBlocked, credential.IsReadOnly, credential.HasUnsyncedChanges);
|
|
}
|
|
|
|
/// <summary>The one-word marker a row shows for its sync state.</summary>
|
|
/// <remarks>
|
|
/// Shared by both row types rather than written twice, because the three states mean the same thing for
|
|
/// every item type and a list where one kind said "not synced" and the other "unsynced" would read as two
|
|
/// different conditions.
|
|
/// </remarks>
|
|
internal static class ItemBadge
|
|
{
|
|
internal static string For(bool isBlocked, bool isReadOnly, bool hasUnsyncedChanges) =>
|
|
(isBlocked, isReadOnly, hasUnsyncedChanges) switch
|
|
{
|
|
(true, _, _) => "rejected",
|
|
(_, true, _) => "newer version",
|
|
(_, _, true) => "not synced",
|
|
_ => string.Empty,
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// A terminal session that has just opened.
|
|
/// </summary>
|
|
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
|
/// <param name="label">The host's name, as the vault has it.</param>
|
|
/// <param name="address">The account and endpoint actually dialled.</param>
|
|
internal sealed class TerminalSessionEventArgs(
|
|
Guid attemptId,
|
|
uint sessionId,
|
|
string label,
|
|
string address) : EventArgs
|
|
{
|
|
/// <summary>Which attempt this session came out of.</summary>
|
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
|
internal Guid AttemptId { get; } = attemptId;
|
|
|
|
internal uint SessionId { get; } = sessionId;
|
|
|
|
internal string Label { get; } = label;
|
|
|
|
internal string Address { get; } = address;
|
|
}
|
|
|
|
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
|
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
|
/// <param name="label">The host's name, as the vault has it.</param>
|
|
/// <param name="address">Who this will be logged in as, and where.</param>
|
|
/// <remarks>
|
|
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
|
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
|
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
|
/// has no vault to read it from.
|
|
/// </remarks>
|
|
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
|
|
{
|
|
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
|
/// <remarks>
|
|
/// Carried by all three events, because several connections can be in flight at once now that one no
|
|
/// longer blocks the window — so "which tab is this about" cannot be answered by "the most recent one".
|
|
/// </remarks>
|
|
internal Guid AttemptId { get; } = attemptId;
|
|
|
|
internal string Label { get; } = label;
|
|
|
|
internal string Address { get; } = address;
|
|
}
|
|
|
|
/// <summary>A connection that was asked for and did not happen.</summary>
|
|
/// <param name="attemptId">The attempt that has just ended.</param>
|
|
/// <param name="reason">What to say about it, in the tab.</param>
|
|
/// <param name="isAwaitingAnAnswer">
|
|
/// Whether the connection stopped on a question rather than on a failure.
|
|
/// </param>
|
|
/// <remarks>
|
|
/// The two kinds are genuinely different and the shell treats them differently. A refusal is a dead end and
|
|
/// the tab keeps it: connecting no longer blocks the window, so the user may well be looking at something
|
|
/// else by now, and a tab that vanished would take the only account of what went wrong with it. An unknown
|
|
/// or changed host key is not a dead end — it is a prompt drawn on the hosts screen, and the connection
|
|
/// resumes the moment it is answered — so the tab goes and the window shows the question instead.
|
|
/// </remarks>
|
|
internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, bool isAwaitingAnAnswer)
|
|
: EventArgs
|
|
{
|
|
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
|
|
internal Guid AttemptId { get; } = attemptId;
|
|
|
|
internal string Reason { get; } = reason;
|
|
|
|
/// <inheritdoc cref="ConnectionFailedEventArgs" path="/param[@name='isAwaitingAnAnswer']" />
|
|
internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer;
|
|
}
|
|
|
|
/// <summary>A conflict, as a row.</summary>
|
|
internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
|
{
|
|
internal Guid Id => notice.Id;
|
|
|
|
internal string Summary => notice.Summary;
|
|
|
|
/// <summary>
|
|
/// The overridden values, one line each.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// This is the whole justification for resolving a conflict automatically. If these were not shown,
|
|
/// the merge would be last-writer-wins with a longer explanation.
|
|
/// </remarks>
|
|
// The lambda parameter is 'entry' rather than the obvious 'field': C# 14 made that a contextual
|
|
// keyword inside a property accessor, and this whole expression is one.
|
|
internal string Detail => notice.Fields.Count == 0
|
|
? string.Empty
|
|
: string.Join(
|
|
Environment.NewLine,
|
|
notice.Fields.Select(entry => entry.DiscardedWasRemoval
|
|
? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept"
|
|
: $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'"));
|
|
|
|
internal bool HasDetail => notice.Fields.Count > 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Which kind of item the vault column is showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One at a time, chosen by a selector at the top of the column. The alternative was every kind stacked in
|
|
/// one scrolling column, which is what the column did with two of them and would not survive a third: two
|
|
/// lists and two editors already only just fit at the window's minimum height, and credentials are coming.
|
|
/// A section is also the unit the column's own layout is measured in — see
|
|
/// <c>DodoSSH.Client.App.Layout.Tests</c>, which lays out one of these at a time because that is all a user
|
|
/// can ever see at once.
|
|
/// </remarks>
|
|
internal enum VaultSection
|
|
{
|
|
/// <summary>Every kind at once, which is where the screen opens.</summary>
|
|
All,
|
|
|
|
/// <summary>The SSH keys hosts authenticate with.</summary>
|
|
Keys,
|
|
|
|
/// <summary>The usernames and passwords they authenticate with instead.</summary>
|
|
/// <remarks>
|
|
/// There was a fourth, for the host keys this user has approved. It is a screen of its own now — see
|
|
/// <c>KnownHostsScreen</c> — and the member is gone rather than left unused, because a value this
|
|
/// screen's command would still accept and no longer draw anything for is a trap with no upside.
|
|
/// </remarks>
|
|
Credentials,
|
|
|
|
/// <summary>The tags hosts wear.</summary>
|
|
/// <remarks>
|
|
/// The odd one out, and here anyway. A tag holds no secret at all — it is a name, and the reason it is
|
|
/// an item rather than a string on a host is that renaming it should be one write instead of twenty.
|
|
/// That rename needs somewhere to happen, and deleting does too, or the host editor's picker fills with
|
|
/// names nobody uses and never empties. This is where every other item kind already lives.
|
|
/// </remarks>
|
|
Tags,
|
|
|
|
/// <summary>S3-compatible buckets, and the keys that reach them.</summary>
|
|
/// <remarks>
|
|
/// A category here rather than a screen of its own, unlike the approved host keys: a bucket is something
|
|
/// somebody creates and edits and whose secret has to be kept, which is exactly what the other two
|
|
/// categories are. A pin is not.
|
|
/// </remarks>
|
|
Buckets,
|
|
}
|
|
|
|
/// <summary>What kind of thing a row in the vault table is.</summary>
|
|
/// <remarks>
|
|
/// Two, since the pins left. Both are things somebody created on purpose and can edit, which is what the
|
|
/// table's shared shape now assumes throughout.
|
|
/// </remarks>
|
|
internal enum VaultItemKind
|
|
{
|
|
/// <summary>An SSH key.</summary>
|
|
Key,
|
|
|
|
/// <summary>A stored username and password.</summary>
|
|
Credential,
|
|
|
|
/// <summary>An S3-compatible bucket.</summary>
|
|
ObjectStore,
|
|
|
|
/// <summary>A tag a host can wear.</summary>
|
|
Tag,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One row of the vault table, whatever kind of item it is.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The table has one shape and a <c>TYPE</c> column, which is what lets a single category show every kind
|
|
/// at once — and that is the only reason this projection exists. It is deliberately a view of a typed row
|
|
/// rather than a replacement for one: selecting here sets the typed selection the editors and the delete
|
|
/// commands already act on, so nothing downstream had to learn about a second way of naming an item.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Nothing here is a secret.</b> <see cref="Detail"/> is what is <em>known about</em> an item — whether a
|
|
/// key has a passphrase stored, which account a password is for, what a pin's fingerprint is — never the key
|
|
/// or the password itself. The same rule the three list templates already followed, now in one place where
|
|
/// it is harder to break by pointing a template at the obvious property.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="Kind">Which of the three lists this came from.</param>
|
|
/// <param name="EntityId">The item, so a selection can be mapped back.</param>
|
|
/// <param name="Name">The label the user gave it.</param>
|
|
/// <param name="Type">The one-word kind, for the table's TYPE column.</param>
|
|
/// <param name="Detail">What is known about it.</param>
|
|
/// <param name="Badge">Its sync state, or empty.</param>
|
|
/// <param name="HasUnsyncedChanges">
|
|
/// Whether this machine has a change to this item that the server has not accepted. Carried separately from
|
|
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
|
|
/// by comparing it against the literal "not synced" would break the day that wording improves.
|
|
/// </param>
|
|
/// <summary>One vault, as an option in the "file this into" picker.</summary>
|
|
/// <param name="VaultId">The vault.</param>
|
|
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
|
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
|
|
internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal)
|
|
{
|
|
/// <summary>
|
|
/// What the picker shows.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A team vault is marked as one. The whole risk this picker introduces is putting a credential
|
|
/// somewhere more people can read it, so the option that does that must not look like the option
|
|
/// that does not.
|
|
/// </remarks>
|
|
internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
|
|
}
|
|
|
|
internal sealed record VaultItemRowViewModel(
|
|
VaultItemKind Kind,
|
|
Guid EntityId,
|
|
string Name,
|
|
string Type,
|
|
string Detail,
|
|
string Badge,
|
|
bool HasUnsyncedChanges)
|
|
{
|
|
/// <summary>Whether this row has anything to say about its sync state.</summary>
|
|
internal bool HasBadge => Badge.Length > 0;
|
|
}
|
|
|
|
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
|
internal enum DeletionTarget
|
|
{
|
|
/// <summary>A host, from the sidebar beside the terminal.</summary>
|
|
Host,
|
|
|
|
/// <summary>An SSH key, from the vault screen.</summary>
|
|
Key,
|
|
|
|
/// <summary>A stored password, from the vault screen.</summary>
|
|
Credential,
|
|
|
|
/// <summary>A group, from the panel beside the host sidebar.</summary>
|
|
Group,
|
|
|
|
/// <summary>A bucket, from the vault screen.</summary>
|
|
ObjectStore,
|
|
|
|
/// <summary>A tag, from the vault screen.</summary>
|
|
Tag,
|
|
}
|
|
|
|
/// <summary>
|
|
/// A deletion that has been asked for and not yet agreed to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A state rather than a dialog, on the same reasoning as the sign-out confirmation — see
|
|
/// <c>MainWindowViewModel.IsConfirmingSignOut</c>. What makes it worth having at all is that the sentences
|
|
/// below are <em>computed</em>: how many hosts authenticate with the key about to go, whether a terminal is
|
|
/// open on the host about to go, and whether this machine can push the tombstone yet. A confirmation that
|
|
/// only said "are you sure?" would be a click to train people out of.
|
|
/// </para>
|
|
/// <para>
|
|
/// It carries the item's id rather than pointing at the selection, so that whatever moves the selection
|
|
/// between the question and the answer — a background sync, a filter, a click in the list — cannot turn an
|
|
/// agreement about one item into the deletion of another.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="Target">Which list to delete from.</param>
|
|
/// <param name="EntityId">The item the question is about.</param>
|
|
/// <param name="Question">The question itself, naming the item.</param>
|
|
/// <param name="Consequence">Where it goes, and how far.</param>
|
|
/// <param name="Usage">
|
|
/// What is riding on this particular item — hosts that authenticate with it, a terminal open on it — or
|
|
/// empty when nothing is. The line that changes the answer, as opposed to the one every deletion shares.
|
|
/// </param>
|
|
internal sealed record DeletionRequest(
|
|
DeletionTarget Target,
|
|
Guid EntityId,
|
|
string Question,
|
|
string Consequence,
|
|
string Usage)
|
|
{
|
|
/// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
|
|
internal bool HasUsage => Usage.Length > 0;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets this machine online, if it can be.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Supplied by the shell, which owns the connection and the remembered sign-in behind it. It is asked
|
|
/// once per synchronisation pass rather than once per vault, and that is what makes coming back from a
|
|
/// closed lid automatic: a laptop that unlocks on a train has no connection and gets one within a minute
|
|
/// of reaching a network, with nothing pressed.
|
|
/// </para>
|
|
/// <para>
|
|
/// Returns null for every reason a machine may be offline — no remembered sign-in, no network, a token
|
|
/// the provider has stopped accepting — because the vault's answer to all of them is the same: work
|
|
/// locally and queue.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken cancellationToken);
|
|
|
|
/// <summary>
|
|
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a
|
|
/// delete disappears immediately whether or not the network is there. Nothing here waits on a server to
|
|
/// show a change.
|
|
/// </para>
|
|
/// <para>
|
|
/// Syncing then happens on its own: once when the vault opens, straight after any local change, and on a
|
|
/// timer while it stays open. The Sync button remains, because a person who has just been handed a
|
|
/// credential wants to know now rather than within the minute — but nothing depends on it being pressed.
|
|
/// A background pass is deliberately quieter than the button: see <see cref="AutoSyncAsync" />.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Being offline is a state a pass tries to leave, not one it gives up on.</b> Every pass asks the
|
|
/// shell for a connection rather than reading one it was handed at unlock — see
|
|
/// <see cref="ServerReconnectHandler" /> — so a machine that unlocked with no network comes online by
|
|
/// itself once it has one, and a sign-in survives a restart without a browser opening.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Everything a connection needs is in the vault.</b> Keys, passwords and host key trust are all synced
|
|
/// items, so each is stored once and available on every machine — approving a fingerprint here approves it on
|
|
/// every device and survives a restart. A typed password is what is left when a host is bound to nothing, and
|
|
/// that is now a choice rather than the only option this interface offered.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A key or a password belongs to a host.</b> Each host names the one thing it authenticates with, or
|
|
/// nothing, and that choice is a field in its encrypted payload — so it follows the host to every machine
|
|
/// rather than being made again per connection. The two are mutually exclusive and the editor offers them
|
|
/// through a single picker, which is what makes the illegal combination unrepresentable rather than merely
|
|
/// invalid. The cost is a payload schema version, paid only by hosts that actually bind something: see
|
|
/// <c>HostSecretCodec.CurrentSchemaVersion</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="copyToClipboard">
|
|
/// Puts one line of text on the system clipboard, or null where there is none.
|
|
/// <para>
|
|
/// A delegate rather than Avalonia's <c>IClipboard</c>, for the reason <c>SignInHandler</c> is one: the
|
|
/// clipboard is reached through <c>TopLevel.GetTopLevel(control)</c>, so taking it directly would make this
|
|
/// view model need a visual — and every test that drives it need a window. Null is a machine that has no
|
|
/// clipboard rather than one that failed to copy, and the difference is worth saying out loud.
|
|
/// </para>
|
|
/// </param>
|
|
internal sealed partial class VaultViewModel(
|
|
VaultSession session,
|
|
TerminalWorkspace workspace,
|
|
VaultKnownHostStore knownHosts,
|
|
Func<IVaultServer?> connection,
|
|
ServerReconnectHandler? reconnect = null,
|
|
Func<string, Task>? copyToClipboard = null,
|
|
ConnectionRecorder? connectionLog = null) : ObservableObject, IAsyncDisposable
|
|
{
|
|
/// <remarks>
|
|
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
|
/// server almost nothing; the number that matters is how stale a teammate's change may look, and a
|
|
/// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake,
|
|
/// and a change made on this machine does not wait for the timer anyway — saving pushes immediately.
|
|
/// </remarks>
|
|
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
|
|
|
|
/// <summary>How often the logs are pruned, at most.</summary>
|
|
/// <remarks>
|
|
/// Hours rather than minutes, because pruning writes tombstones that sync. Retention is measured in days
|
|
/// and thousands of entries, so a pass that ran six hours late has nothing to catch up on — and one that
|
|
/// ran every minute would be a machine talking to a server all day about housekeeping.
|
|
/// </remarks>
|
|
private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(6);
|
|
|
|
/// <summary>When the logs were last pruned, or null when this session has not pruned yet.</summary>
|
|
private DateTimeOffset? lastPruned;
|
|
|
|
/// <summary>Serialises every synchronisation pass, whether a button pressed it or a timer did.</summary>
|
|
private readonly SemaphoreSlim syncGate = new(1, 1);
|
|
|
|
/// <summary>
|
|
/// The groups as they came out of the vault, before the host counts are attached.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Held between the group reload and the host reload, which are two passes because a group row says how
|
|
/// many hosts name it and the hosts are read second. See <see cref="RebuildGroups"/>.
|
|
/// </remarks>
|
|
private IReadOnlyList<VaultItem<HostGroupSecret>> groupItems = [];
|
|
|
|
/// <summary>
|
|
/// The same groups by id, which is the shape the inheritance walk takes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Cached beside <see cref="groupItems"/> rather than built per call, because resolving is on the path
|
|
/// 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 Dictionary<Guid, HostGroupSecret> groupsById = [];
|
|
|
|
/// <summary>
|
|
/// The tags as they came out of the vault, before the host counts are attached.
|
|
/// </summary>
|
|
/// <inheritdoc cref="groupItems" path="/remarks" />
|
|
private IReadOnlyList<VaultItem<TagSecret>> tagItems = [];
|
|
|
|
/// <summary>
|
|
/// The same tags by id, which is what a host's <see cref="HostSecret.TagIds"/> resolves through.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every readable vault's, like <see cref="groupsById"/> and for a weaker version of the same reason.
|
|
/// A tag id that does not resolve draws no chip, so a host in a team's vault would silently look
|
|
/// untagged — which costs a label rather than a connection, but is just as confusing to look at and
|
|
/// costs nothing to avoid.
|
|
/// </remarks>
|
|
private Dictionary<Guid, TagSecret> tagsById = [];
|
|
|
|
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
|
|
private readonly HashSet<Guid> collapsedGroups = [];
|
|
|
|
private CancellationTokenSource? autoSync;
|
|
private Task? autoSyncLoop;
|
|
private bool disposed;
|
|
|
|
/// <summary>
|
|
/// The open vault this view model is showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Exposed for the operations only an open session can perform — sealing the bundle to a new device key,
|
|
/// chiefly — which the shell drives rather than this view model. Ownership does not move: this type
|
|
/// disposes it, and a caller must not.
|
|
/// </remarks>
|
|
internal VaultSession Session => session;
|
|
|
|
/// <summary>The hosts to show, unpushed local state included.</summary>
|
|
/// <remarks>
|
|
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
|
|
/// host key list checks itself against, so a filter applied here would change what the application can
|
|
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
|
|
/// </remarks>
|
|
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
|
|
|
/// <summary>The hosts the sidebar is showing: the filter applied, nothing else.</summary>
|
|
/// <remarks>
|
|
/// A second collection rather than a filtered view over the first, because the sidebar's list has to be
|
|
/// one <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
|
|
/// terminal gives it back, and neither of those survives being split across several lists.
|
|
/// </remarks>
|
|
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
|
|
|
/// <summary>
|
|
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A vault with no groups produces no headings at all</b>, so this is <see cref="VisibleHosts"/> in
|
|
/// the same order, and the sidebar looks exactly as it did before groups existed. The feature is
|
|
/// invisible until it is used, which is the point: somebody with eleven machines and no wish to file them
|
|
/// should not be shown a heading saying so.
|
|
/// </para>
|
|
/// <para>
|
|
/// Kept beside <see cref="VisibleHosts"/> rather than replacing it. The count on the section heading, the
|
|
/// filter's own arithmetic and every test that asks what the sidebar is showing all mean hosts — a
|
|
/// collection whose <c>Count</c> silently included headings would be wrong in each of them.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];
|
|
|
|
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
|
|
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
|
|
|
|
/// <summary>The saved commands in this vault, unpushed local state included.</summary>
|
|
/// <remarks>
|
|
/// Held here rather than on the screen that shows them, for the reason every other list is: this is where
|
|
/// the reload and the automatic push are wired, and a second copy of that wiring is a second place for it
|
|
/// to be forgotten. <c>SnippetsViewModel</c> is the filter and the editor over the top.
|
|
/// </remarks>
|
|
internal ObservableCollection<SnippetRowViewModel> Snippets { get; } = [];
|
|
|
|
/// <summary>
|
|
/// What the sidebar's one group heading says.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
|
|
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
|
|
/// presented as structure.
|
|
/// <para>
|
|
/// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold
|
|
/// several, and then the heading stops naming one of them and each row names its own — a heading that
|
|
/// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this
|
|
/// interface is otherwise careful about.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string HostsHeading =>
|
|
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
|
|
|
|
/// <summary>Whether the host list under the heading is folded away.</summary>
|
|
[ObservableProperty]
|
|
private bool areHostsExpanded = true;
|
|
|
|
/// <summary>The SSH keys to show, unpushed local state included.</summary>
|
|
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
|
|
|
|
/// <summary>The stored credentials to show, unpushed local state included.</summary>
|
|
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
|
|
|
/// <summary>The buckets to show, unpushed local state included.</summary>
|
|
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
|
|
|
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
|
/// <remarks>
|
|
/// Counted like a group's rows are, and read after the hosts for the same reason: the count is a
|
|
/// property of the host list rather than of the tag.
|
|
/// </remarks>
|
|
internal ObservableCollection<TagRowViewModel> Tags { get; } = [];
|
|
|
|
/// <summary>The host keys this user has approved.</summary>
|
|
internal ObservableCollection<KnownHostRowViewModel> KnownHostPins { get; } = [];
|
|
|
|
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
|
|
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
|
|
|
|
internal string VaultName =>
|
|
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";
|
|
|
|
/// <summary>
|
|
/// The vaults a new item may be filed into: readable, and writable by this account.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one
|
|
/// it can read but not write is a team vault this member is a viewer of — offering either would end
|
|
/// in a Save that fails, one of them locally and one at the server.
|
|
/// </remarks>
|
|
internal ObservableCollection<VaultChoiceViewModel> TargetVaults { get; } = [];
|
|
|
|
/// <summary>
|
|
/// Where the next new item goes.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Falls back to the session's active vault, which is the personal one wherever there is one. Filing
|
|
/// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is
|
|
/// visible to people who should not have it, and moving it afterwards means deleting and retyping.
|
|
/// </remarks>
|
|
internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;
|
|
|
|
/// <summary>Whether there is more than one vault to choose between.</summary>
|
|
/// <remarks>
|
|
/// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is
|
|
/// a question with no answer, and for most people this stays at one for ever.
|
|
/// </remarks>
|
|
internal bool HasVaultChoice => TargetVaults.Count > 1;
|
|
|
|
[ObservableProperty]
|
|
private VaultChoiceViewModel? selectedTargetVault;
|
|
|
|
[ObservableProperty]
|
|
private HostRowViewModel? selectedHost;
|
|
|
|
/// <summary>
|
|
/// What the sidebar's <c>ListBox</c> has selected, which may be a heading.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The list holds two kinds of row and only one of them is a host, so the control's selection and the
|
|
/// application's selection are no longer the same thing. This is the control's; <see cref="SelectedHost"/>
|
|
/// stays the application's, and everything that acts on a host — connecting, editing, deleting — goes on
|
|
/// reading that one.
|
|
/// </para>
|
|
/// <para>
|
|
/// A heading click is bounced back to whatever was selected before it rather than being left highlighted
|
|
/// or clearing the selection. Clearing would mean the buttons at the foot of the sidebar quietly stopped
|
|
/// working because somebody folded a group away; leaving it highlighted would mean a selected row that
|
|
/// none of those buttons act on.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private ISidebarRow? selectedSidebarRow;
|
|
|
|
[ObservableProperty]
|
|
private HostGroupRowViewModel? selectedGroup;
|
|
|
|
/// <summary>What the group name box holds, for both creating and renaming.</summary>
|
|
[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;
|
|
|
|
[ObservableProperty]
|
|
private SshKeyRowViewModel? selectedKey;
|
|
|
|
[ObservableProperty]
|
|
private CredentialRowViewModel? selectedCredential;
|
|
|
|
[ObservableProperty]
|
|
private ObjectStoreRowViewModel? selectedObjectStore;
|
|
|
|
[ObservableProperty]
|
|
private TagRowViewModel? selectedTag;
|
|
|
|
// ---- The tag editor ----
|
|
// A fifth set, and the smallest by a long way: a tag is a name. It is still its own pair rather than
|
|
// sharing the group editor's box, for the reason the other four are separate — a half-typed tag
|
|
// appearing inside a group's name field is the kind of thing that only shows up in a bug report.
|
|
|
|
[ObservableProperty]
|
|
private bool isEditingTag;
|
|
|
|
[ObservableProperty]
|
|
private string tagEditorLabel = string.Empty;
|
|
|
|
/// <summary>The tag being edited, or null when creating.</summary>
|
|
private Guid? editingTagId;
|
|
|
|
/// <summary>Which vault the tag editor will write to.</summary>
|
|
/// <inheritdoc cref="editingHostVaultId" path="/remarks" />
|
|
private Guid editingTagVaultId;
|
|
|
|
[ObservableProperty]
|
|
private KnownHostRowViewModel? selectedKnownHost;
|
|
|
|
/// <summary>
|
|
/// What the sidebar's filter box holds.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Matched against the label, the address and the notes, case-insensitively, because those are the three
|
|
/// things a person remembers a machine by. It narrows <see cref="HostGroups"/> only: the selection, the
|
|
/// connect path and everything else read <see cref="Hosts"/>, so filtering can never make a host
|
|
/// unusable — only unlisted.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string hostFilter = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string status = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private int pendingChanges;
|
|
|
|
/// <summary>
|
|
/// Whether the last synchronisation attempt failed to reach the server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Holding a connection object is not the same as being able to reach anything, and this is the
|
|
/// difference. <c>IVaultServer</c> is obtained once at sign-in and never dropped, so a laptop whose lid
|
|
/// has been shut all afternoon still has one — and the background pass swallows its socket errors on
|
|
/// purpose, which means nothing else would ever notice.
|
|
/// </para>
|
|
/// <para>
|
|
/// It exists because the titlebar makes a claim now. A green dot saying SYNCED over a machine that has
|
|
/// not reached the server since lunch is exactly the sort of thing this project writes down instead of
|
|
/// implying — and an empty outbox does not rule it out, because an empty outbox on an unreachable
|
|
/// machine is the ordinary state of a laptop nobody has changed anything on.
|
|
/// </para>
|
|
/// <para>
|
|
/// False until proven otherwise rather than the reverse. The auto-sync loop runs a pass the moment the
|
|
/// vault opens, so the honest answer arrives within a moment of unlocking, and starting pessimistic
|
|
/// would flash UNREACHABLE at every launch by somebody who is not.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool lastSyncFailed;
|
|
|
|
[ObservableProperty]
|
|
private int unreadableItems;
|
|
|
|
[ObservableProperty]
|
|
private bool isBusy;
|
|
|
|
// ---- Which kind of item is showing ----
|
|
|
|
/// <remarks>
|
|
/// Settable, and the markup deliberately does not bind a selector's selection to it. A selection binding
|
|
/// would move before <see cref="ShowSection" /> could refuse, leaving a selector highlighting a section
|
|
/// the column is not showing; two plain buttons and a command carry no state of their own and cannot
|
|
/// disagree with this. Tests set it directly, which is the same thing the command does once it has
|
|
/// decided.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private VaultSection section;
|
|
|
|
/// <summary>Whether every kind is showing at once.</summary>
|
|
internal bool ShowsAll => Section is VaultSection.All;
|
|
|
|
/// <inheritdoc cref="ShowsAll" />
|
|
internal bool ShowsKeys => Section is VaultSection.Keys;
|
|
|
|
/// <inheritdoc cref="ShowsAll" />
|
|
internal bool ShowsCredentials => Section is VaultSection.Credentials;
|
|
|
|
/// <inheritdoc cref="ShowsAll" />
|
|
internal bool ShowsBuckets => Section is VaultSection.Buckets;
|
|
|
|
/// <inheritdoc cref="ShowsAll" />
|
|
internal bool ShowsTags => Section is VaultSection.Tags;
|
|
|
|
/// <summary>
|
|
/// The rows the vault table is showing, for whichever category is selected.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Rebuilt whenever the category changes or the lists reload, from the typed lists rather than from
|
|
/// storage — so it costs one pass over what is already decrypted in memory and can never disagree with
|
|
/// the lists the editors act on.
|
|
/// </remarks>
|
|
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
|
|
|
|
/// <summary>
|
|
/// The selected row of the vault table.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Setting it sets the matching typed selection, which is what every editor and every delete command
|
|
/// reads. The typed selections stay the state; this is the way the table names one of them.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private VaultItemRowViewModel? selectedVaultItem;
|
|
|
|
/// <summary>What the vault screen's header calls the category showing.</summary>
|
|
internal string SectionTitle => Section switch
|
|
{
|
|
VaultSection.Keys => "SSH KEYS",
|
|
VaultSection.Credentials => "PASSWORDS",
|
|
VaultSection.Buckets => "BUCKETS",
|
|
VaultSection.Tags => "TAGS",
|
|
_ => "ALL ITEMS",
|
|
};
|
|
|
|
/// <summary>
|
|
/// What the header says about the category, under its name.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The design says "18 items · 12 shared". Sharing does not exist — every item in this vault is this
|
|
/// user's — so the second half is what this build can actually count instead: how many of the ones on
|
|
/// screen this machine has not managed to push yet.
|
|
/// <para>
|
|
/// Counted over the rows showing rather than over the whole outbox, which is what this said first and
|
|
/// was wrong in a way a screenshot made obvious: "7 items · 12 not synced" under a list of seven reads
|
|
/// as twelve of those seven. The outbox counts hosts too, and hosts are a different screen.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string SectionSummary
|
|
{
|
|
get
|
|
{
|
|
var items = VaultItems.Count == 1 ? "1 item" : $"{VaultItems.Count} items";
|
|
var waiting = VaultItems.Count(row => row.HasUnsyncedChanges);
|
|
|
|
return waiting == 0 ? items : $"{items} · {waiting} not synced";
|
|
}
|
|
}
|
|
|
|
/// <summary>Everything on this screen, which is the keychain less the hosts and the pins.</summary>
|
|
/// <remarks>
|
|
/// Both of those have screens of their own now. Counting a pin here would put a number on the ALL
|
|
/// category that the ALL category does not list.
|
|
/// </remarks>
|
|
/// <summary>What the ALL chip counts, which is everything ALL actually shows.</summary>
|
|
/// <remarks>
|
|
/// Buckets were already missing from this before tags were, so the number under a chip labelled ALL has
|
|
/// been smaller than the list it opens for as long as there have been four kinds. Fixed here rather
|
|
/// than left consistent: a count that disagrees with the rows beneath it is worse than no count, and
|
|
/// adding a fifth kind to the same expression would have widened the gap rather than caused it.
|
|
/// </remarks>
|
|
internal int TotalItemCount =>
|
|
Keys.Count + Credentials.Count + ObjectStores.Count + Tags.Count;
|
|
|
|
internal bool HasVaultItems => VaultItems.Count > 0;
|
|
|
|
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
|
|
|
/// <summary>Whether the selected row is one with an editor behind it.</summary>
|
|
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
|
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
|
|
|
|
/// <summary>Whether the selected row is an SSH key, which is the only kind with a public half to copy.</summary>
|
|
internal bool SelectedItemIsKey => SelectedVaultItem?.Kind is VaultItemKind.Key;
|
|
|
|
/// <summary>What the detail pane calls the block under the chips.</summary>
|
|
internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch
|
|
{
|
|
VaultItemKind.Credential => "ACCOUNT",
|
|
_ => "WHAT IS STORED",
|
|
};
|
|
|
|
internal bool HasUnreadableItems => UnreadableItems > 0;
|
|
|
|
/// <remarks>
|
|
/// A sentence rather than a number, because the number alone reads as a count of something you have
|
|
/// rather than of something you cannot open — and what to do about it is not guessable.
|
|
/// </remarks>
|
|
internal string UnreadableSummary => UnreadableItems == 1
|
|
? "1 item will not decrypt"
|
|
: $"{UnreadableItems} items will not decrypt";
|
|
|
|
/// <summary>What an empty category says instead of showing an empty grid.</summary>
|
|
internal string EmptySectionMessage => Section switch
|
|
{
|
|
VaultSection.Keys =>
|
|
"No SSH keys yet. Paste one in and bind a host to it, and that host stops asking for a password.",
|
|
VaultSection.Credentials =>
|
|
"No stored passwords yet. Add one to stop typing the same password into every connection.",
|
|
VaultSection.Buckets =>
|
|
"No buckets yet. Add one to browse S3-compatible storage beside a host on the Files screen.",
|
|
VaultSection.Tags =>
|
|
"No tags yet. Add one here, or from a host's editor, and it becomes a chip you can put on "
|
|
+ "twenty machines and rename once.",
|
|
_ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.",
|
|
};
|
|
|
|
// ---- The editor ----
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
|
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
|
private bool isEditing;
|
|
|
|
/// <summary>
|
|
/// Whether the group editor is open as a surface of its own.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The desktop does not need this: its group editor is a bar under the group list that is always there,
|
|
/// and <see cref="EditingGroupId"/> is enough to tell adding from saving. The phone has no room for a
|
|
/// permanent bar, so its group editor is a card that replaces the list — and "is the card showing" is a
|
|
/// different question from "which group is being edited", because adding one has no id.
|
|
/// </para>
|
|
/// <para>
|
|
/// Held here rather than on the phone's own control so that the two heads cannot disagree about
|
|
/// whether an editor is open. The back gesture and the floating button both read it.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
|
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
|
private bool isEditingGroup;
|
|
|
|
/// <summary>
|
|
/// Whether the add sheet is showing over the host list.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The phone's answer to a <c>+</c> that has two things to offer. It is a separate flag from the two
|
|
/// editors because it sits <em>before</em> either of them: the sheet asks which kind, and choosing
|
|
/// closes the sheet and opens that kind's editor. See <see cref="OpenAddSheet"/>.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
|
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
|
private bool isAddSheetOpen;
|
|
|
|
/// <summary>
|
|
/// Whether anything the host screen can put over its list is showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One property rather than three tests at each call site, and it exists because two controls need
|
|
/// exactly this question and would otherwise each answer it their own way: the floating <c>+</c> hides
|
|
/// while any of them is up — a button that opens an editor on top of an open editor is a button that
|
|
/// does nothing — and the back gesture closes them before it considers leaving the screen.
|
|
/// </remarks>
|
|
internal bool AnEditorIsOpen => IsAddSheetOpen || IsEditing || IsEditingGroup;
|
|
|
|
/// <summary>
|
|
/// Whether the phone's connect bar has anything to be about.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A host is chosen and nothing is covering the list. Both halves are needed and the second is the one
|
|
/// worth stating: the editor cards replace the list rather than floating over it, so a bar left showing
|
|
/// underneath would carry CONNECT and EDIT for a host that is no longer on screen — and under the host
|
|
/// editor, for the very record being typed into.
|
|
/// </remarks>
|
|
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;
|
|
|
|
[ObservableProperty]
|
|
private string editorLabel = string.Empty;
|
|
|
|
[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]
|
|
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
|
|
private int? editorPort;
|
|
|
|
[ObservableProperty]
|
|
private string editorUsername = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string editorNotes = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool editorRelayEnabled;
|
|
|
|
/// <summary>
|
|
/// What the authentication picker offers: a typed password, then every key, then every credential.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Rebuilt when the editor opens rather than kept in step with the two lists. A background sync could pull
|
|
/// a new key while a host is being edited, and having the picker's contents change under the user mid-edit
|
|
/// is worse than the list being a minute stale — only one editor may be open at a time, so the only way to
|
|
/// add a key or a credential is to close this one anyway.
|
|
/// </remarks>
|
|
internal ObservableCollection<AuthenticationChoice> EditorAuthenticationChoices { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private AuthenticationChoice? editorSelectedAuthentication;
|
|
|
|
/// <summary>What the group picker offers: "no group", then every group.</summary>
|
|
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
|
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 wears, as the picker leaves them.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The authority while an editor is open, not <see cref="EditorTagChoices"/>: the chips are a
|
|
/// projection of this and are rebuilt from it, so a tag the vault no longer offers — deleted on another
|
|
/// machine between the editor opening and Save — is still carried through rather than dropped by an
|
|
/// edit that was about something else.
|
|
/// </remarks>
|
|
private TagSet editorTagIds = TagSet.Empty;
|
|
|
|
/// <summary>
|
|
/// Every tag in the vault, as a chip, with whether the host being edited wears it.
|
|
/// </summary>
|
|
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
|
internal ObservableCollection<TagChoice> EditorTagChoices { get; } = [];
|
|
|
|
/// <summary>Whether there is any tag to offer, which is what draws the picker at all.</summary>
|
|
/// <remarks>
|
|
/// A vault with no tags shows the new-tag box and nothing else. An empty row of chips with a heading
|
|
/// over it would be a control that looks broken rather than one that has nothing to say.
|
|
/// </remarks>
|
|
internal bool HasTagChoices => EditorTagChoices.Count > 0;
|
|
|
|
/// <summary>The name in the editor's "new tag" box.</summary>
|
|
[ObservableProperty]
|
|
private string editorNewTag = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Puts a tag on the host being edited, or takes it off.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Applies to the editor's own set rather than saving anything. The host is written by
|
|
/// <see cref="BuildHost"/> like every other field, so cancelling an edit drops the tagging with the
|
|
/// rest of it — which is what a user who pressed CANCEL asked for.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void ToggleEditorTag(TagChoice? choice)
|
|
{
|
|
if (choice is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
editorTagIds = choice.IsWorn
|
|
? TagSet.Create(editorTagIds.Where(id => id != choice.EntityId))
|
|
: TagSet.Create([.. editorTagIds, choice.EntityId]);
|
|
|
|
BuildTagChoices();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates a tag from the editor's box and puts it on the host being edited.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The moment a tag is wanted is the moment somebody is tagging a host and finds it does not exist yet,
|
|
/// so this is where creating one belongs. The keychain screen has the list for renaming and deleting;
|
|
/// making a user go there first, come back, and find their half-typed host gone would be the wrong way
|
|
/// round.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It writes to the vault immediately, unlike everything else in this editor.</b> A tag is a shared
|
|
/// item with an id, and a host can only name an id that exists — so there is nothing to defer. The
|
|
/// consequence is honest rather than hidden: cancelling the host edit leaves the tag behind, because
|
|
/// the tag was never part of the host.
|
|
/// </para>
|
|
/// <para>
|
|
/// A name that already exists is offered rather than duplicated. Two tags called "staging" are storable
|
|
/// — see <see cref="TagSecret.TryValidate"/> for why refusing the second would be worse — but creating
|
|
/// one by hand, from a box, beside a chip of the same name is a slip rather than an intention.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task AddEditorTagAsync(CancellationToken cancellationToken)
|
|
{
|
|
var label = EditorNewTag.Trim();
|
|
|
|
if (label.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Ordinal-ignore-case rather than the current culture's. A tag name is a filter token people type
|
|
// and re-type — "PCI" and "pci" are one tag by anybody's reading — and culture-aware casing would
|
|
// make whether they are the same depend on the phone's locale, which is not a property of the
|
|
// keychain the two machines share.
|
|
if (Tags.FirstOrDefault(
|
|
row => string.Equals(row.Label, label, StringComparison.OrdinalIgnoreCase)) is { } existing)
|
|
{
|
|
editorTagIds = TagSet.Create([.. editorTagIds, existing.EntityId]);
|
|
EditorNewTag = string.Empty;
|
|
BuildTagChoices();
|
|
Status = $"'{existing.Label}' is already in this keychain, so it was used rather than repeated.";
|
|
return;
|
|
}
|
|
|
|
var tag = new TagSecret { Label = label };
|
|
|
|
if (!tag.TryValidate(out var reason))
|
|
{
|
|
Status = reason;
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
// The active vault, not the host's. The picker lists the active vault's tags — exactly as
|
|
// the group picker lists the active vault's groups — so creating one anywhere else would
|
|
// put a chip on the host that the editor beside it could not show. A host in a team's vault
|
|
// can therefore end up naming a personal tag, which is the same cross-vault reference a
|
|
// group already allows and is recorded with it in docs/design-import-gaps.md.
|
|
var entityId = await session.Tags
|
|
.CreateAsync(session.ActiveVaultId, tag, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
editorTagIds = TagSet.Create([.. editorTagIds, entityId]);
|
|
EditorNewTag = string.Empty;
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
BuildTagChoices();
|
|
|
|
Status = $"Added the tag '{tag.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Refills the editor's chips from the vault's tags and the set being edited.</summary>
|
|
/// <remarks>
|
|
/// Rebuilt wholesale rather than mutated, because <see cref="TagChoice"/> is a record and its
|
|
/// <c>IsWorn</c> is part of its value. One toggle therefore replaces the collection, which for a list of
|
|
/// chips is cheaper than raising change notification on each of them.
|
|
/// </remarks>
|
|
private void BuildTagChoices()
|
|
{
|
|
EditorTagChoices.Clear();
|
|
|
|
foreach (var tag in Tags)
|
|
{
|
|
EditorTagChoices.Add(
|
|
new TagChoice(tag.EntityId, tag.Label, editorTagIds.Contains(tag.EntityId)));
|
|
}
|
|
|
|
OnPropertyChanged(nameof(HasTagChoices));
|
|
}
|
|
|
|
/// <summary>The item being edited, or null when creating.</summary>
|
|
private Guid? editingEntityId;
|
|
|
|
/// <summary>
|
|
/// Which vault the editor will write to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Captured when the editor opens rather than read at save time, and there are two different reasons
|
|
/// for that depending on which way the editor was opened. Editing an existing item, it is the vault
|
|
/// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the
|
|
/// target picker said <em>at that moment</em>, so changing the picker afterwards cannot silently move
|
|
/// a half-typed host into a team's vault.
|
|
/// </remarks>
|
|
private Guid editingHostVaultId;
|
|
|
|
/// <summary>Which vault the key editor will write to. See <see cref="editingHostVaultId"/>.</summary>
|
|
private Guid editingKeyVaultId;
|
|
|
|
/// <summary>Which vault the credential editor will write to. See <see cref="editingHostVaultId"/>.</summary>
|
|
private Guid editingCredentialVaultId;
|
|
|
|
/// <summary>
|
|
/// Whether the editor is showing a host that could have a pinned key to forget.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Read by the editor to hide the button while a host is being created, where there is nothing to
|
|
/// withdraw yet. It does not claim a pin exists — answering that would mean a second question to the
|
|
/// known-host store for a button's visibility, and the command already says plainly when there was
|
|
/// nothing to forget.
|
|
/// </remarks>
|
|
internal bool CanForgetHostKey => IsEditing && editingEntityId is not null;
|
|
|
|
// ---- The key editor ----
|
|
// A second set of editor state rather than a shared one. The two editors hold unrelated fields, and
|
|
// sharing them would mean a half-typed host reappearing inside a key editor.
|
|
|
|
[ObservableProperty]
|
|
private bool isEditingKey;
|
|
|
|
[ObservableProperty]
|
|
private string keyEditorLabel = string.Empty;
|
|
|
|
/// <remarks>
|
|
/// Bound to a text box the user pastes a private key into, so this holds key material for as long as
|
|
/// the editor is open, and <see cref="CancelKeyEdit" /> clears it. Neither that nor anything else here
|
|
/// can wipe it — see <c>SshKeySecret</c>, which explains why a .NET string is the honest choice for
|
|
/// this and what it does not buy.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string keyEditorPrivateKey = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string keyEditorPassphrase = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string keyEditorPublicKey = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string keyEditorNotes = string.Empty;
|
|
|
|
/// <summary>The key being edited, or null when creating.</summary>
|
|
private Guid? editingKeyId;
|
|
|
|
// ---- Generating a key ----
|
|
|
|
/// <summary>
|
|
/// Whether the small form in front of generating a key is showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A step of its own rather than two more boxes in the key editor, because the two are opposite
|
|
/// directions: the editor is where a key that already exists is pasted in, and this makes one that does
|
|
/// not exist yet. What it produces lands in that editor unsaved, so there is still exactly one thing in
|
|
/// this application that writes a key, and it is still SAVE.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isGeneratingKey;
|
|
|
|
/// <summary>
|
|
/// What the generated key is called, and the comment written into it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One field for both. The comment is the only thing in a host's <c>authorized_keys</c> that will ever
|
|
/// say where a key came from, and a key whose name here and comment there disagree is one nobody can
|
|
/// match up months later when they are deciding which line to delete.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string generateComment = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private SshKeyAlgorithm generateAlgorithm = SshKeyAlgorithm.Ed25519;
|
|
|
|
/// <inheritdoc cref="ShowsAll" />
|
|
internal bool GeneratesEd25519 => GenerateAlgorithm is SshKeyAlgorithm.Ed25519;
|
|
|
|
/// <inheritdoc cref="ShowsAll" />
|
|
internal bool GeneratesRsa => GenerateAlgorithm is SshKeyAlgorithm.Rsa4096;
|
|
|
|
// ---- The credential editor ----
|
|
// A third set, on the same reasoning as the second: three editors holding unrelated fields, and sharing
|
|
// them would mean a half-typed key reappearing inside a credential.
|
|
|
|
[ObservableProperty]
|
|
private bool isEditingCredential;
|
|
|
|
[ObservableProperty]
|
|
private string credentialEditorLabel = string.Empty;
|
|
|
|
/// <remarks>
|
|
/// Optional, and what makes a credential worth being its own item: one account on twenty machines is
|
|
/// rotated in one place. Blank means "use each host's own username" — see <c>CredentialSecret.Username</c>,
|
|
/// which normalises the two spellings of that to one.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string credentialEditorUsername = string.Empty;
|
|
|
|
/// <remarks>
|
|
/// Holds a password for as long as the editor is open, and <see cref="CancelCredentialEdit" /> clears it —
|
|
/// the same bargain, and the same limits, as the private key box. See <c>CredentialSecret</c>.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string credentialEditorPassword = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string credentialEditorNotes = string.Empty;
|
|
|
|
/// <summary>The credential being edited, or null when creating.</summary>
|
|
private Guid? editingCredentialId;
|
|
|
|
// ---- The bucket editor ----
|
|
// A fourth set, on the same reasoning as the third.
|
|
|
|
[ObservableProperty]
|
|
private bool isEditingObjectStore;
|
|
|
|
[ObservableProperty]
|
|
private string bucketEditorLabel = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string bucketEditorBucket = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string bucketEditorAccessKeyId = string.Empty;
|
|
|
|
/// <remarks>
|
|
/// Holds a secret access key for as long as the editor is open, and cancelling clears it — the same
|
|
/// bargain, and the same limits, as the password box. A secret access key is a password.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string bucketEditorSecretAccessKey = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string bucketEditorRegion = string.Empty;
|
|
|
|
/// <remarks>
|
|
/// Blank means Amazon and the region resolves the host. Anything else is a full URL, which is what makes
|
|
/// this work against a MinIO on somebody's own network.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string bucketEditorEndpoint = string.Empty;
|
|
|
|
/// <remarks>
|
|
/// Defaulted on for a new bucket, which is the opposite of the AWS default and the right guess here:
|
|
/// somebody adding a bucket with a custom endpoint is nearly always pointing at a self-hosted service,
|
|
/// and those have no wildcard DNS. Somebody adding an AWS bucket leaves the endpoint blank, and
|
|
/// <see cref="NewObjectStore"/> turns it off for them.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool bucketEditorUsePathStyle;
|
|
|
|
[ObservableProperty]
|
|
private string bucketEditorNotes = string.Empty;
|
|
|
|
/// <summary>The bucket being edited, or null when creating.</summary>
|
|
private Guid? editingObjectStoreId;
|
|
|
|
// ---- Deleting ----
|
|
|
|
/// <summary>The deletion that has been asked for, or null when nothing has been.</summary>
|
|
/// <remarks>
|
|
/// One at a time, and one for all three kinds. Two armed deletions cannot be told apart by a user
|
|
/// looking at two cards, and this application only ever has one selected item per screen to aim a
|
|
/// question at.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private DeletionRequest? pendingDeletion;
|
|
|
|
internal bool IsConfirmingDeletion => PendingDeletion is not null;
|
|
|
|
/// <summary>Whether the sidebar's row of host buttons is showing.</summary>
|
|
/// <remarks>
|
|
/// Its own property because the markup cannot express <c>!IsEditing && !IsConfirmingDeletion</c>,
|
|
/// and because both halves are the same rule: the question about deleting a host takes the place of the
|
|
/// buttons that asked it, so that DELETE cannot be pressed a second time while its own confirmation is
|
|
/// on screen.
|
|
/// </remarks>
|
|
internal bool ShowsHostActions => !IsEditing && !IsConfirmingHostDeletion;
|
|
|
|
/// <summary>
|
|
/// Whether the question on screen is the one about deleting a host.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The sidebar and the group panel are on the same screen, and there is one pending deletion between
|
|
/// them, so each has to ask whether the question is <em>its own</em> — otherwise deleting a group draws
|
|
/// the group's question inside the host sidebar as well, in a place its buttons never were.
|
|
/// </remarks>
|
|
internal bool IsConfirmingHostDeletion => PendingDeletion?.Target is DeletionTarget.Host;
|
|
|
|
/// <inheritdoc cref="IsConfirmingHostDeletion" />
|
|
internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group;
|
|
|
|
/// <summary>Whether the group panel's buttons are showing.</summary>
|
|
internal bool ShowsGroupActions => !IsConfirmingGroupDeletion;
|
|
|
|
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
|
|
internal bool HasGroups => Groups.Count > 0;
|
|
|
|
/// <summary>What the group panel's save button says.</summary>
|
|
/// <remarks>
|
|
/// "SAVE" rather than the "RENAME" it said while a group was only a name. A button that offers to
|
|
/// rename, pressed after somebody has changed the default port beside it, describes one of the four
|
|
/// things it is about to do.
|
|
/// </remarks>
|
|
internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "SAVE";
|
|
|
|
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
|
/// <inheritdoc cref="ShowsHostActions" />
|
|
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
|
|
|
|
// ---- Connecting ----
|
|
|
|
/// <remarks>
|
|
/// Typed per connection, not persisted unless <see cref="RemembersConnectPassword"/> says otherwise, and
|
|
/// only reached by a host bound to nothing. It stays because not every password is worth storing — a
|
|
/// one-off on a machine somebody will never open again, or one they would rather this vault did not hold
|
|
/// — and because a credential has to be created before it can be bound, which means the first connection
|
|
/// to a new host happens through this box.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string connectPassword = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Whether a password typed here should be kept, so this host stops asking for it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a
|
|
/// password can live. The two-step chore it replaces — add a password under Keychain, then open the host
|
|
/// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand
|
|
/// means typing the secret into a second screen while the first one already has it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Off by default, and it stays a decision.</b> The reason a typed password exists at all is that not
|
|
/// every password belongs in a synchronised vault; remembering silently would move each of them there and
|
|
/// tell nobody. It also only takes effect once the handshake has succeeded — see
|
|
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
|
|
/// the one not worth keeping.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool remembersConnectPassword;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
|| 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?.Resolved.Binding switch
|
|
{
|
|
{ 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;
|
|
|
|
[ObservableProperty]
|
|
private string? hostKeyMismatch;
|
|
|
|
/// <summary>
|
|
/// Raised once a terminal session is open and its renderer has it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// An event rather than a property because handing the terminal the keyboard is something that
|
|
/// happens, not something that is true: connecting a second host while one is already open has to
|
|
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
|
|
/// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
|
|
/// controls directly.
|
|
/// </para>
|
|
/// <para>
|
|
/// It carries the session, because the shell opens a tab for it and the shell is where tabs live. The
|
|
/// vault is the only thing that knows what this session is <em>of</em> — a host's name is a decrypted
|
|
/// item — so the naming happens here and the tab list happens there.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;
|
|
|
|
/// <summary>
|
|
/// Raised the moment a connection is asked for, before anything has been dialled.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The other half of <see cref="SessionOpened"/>, and the reason connecting no longer makes the window
|
|
/// sit still: the shell opens a tab from this, so the strip shows what is being connected to while the
|
|
/// handshake is still happening, and every other screen stays usable. Exactly one of
|
|
/// <see cref="SessionOpened"/> and <see cref="ConnectionFailed"/> follows it, carrying the same
|
|
/// <c>AttemptId</c>.
|
|
/// </remarks>
|
|
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
|
|
|
|
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
|
|
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
|
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
|
|
|
internal bool HasPendingHostKey => PendingHostKey is not null;
|
|
|
|
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
|
|
|
internal bool HasConflicts => Conflicts.Count > 0;
|
|
|
|
/// <summary>Reads the vault into the list and says what is in it.</summary>
|
|
internal async Task LoadAsync(CancellationToken cancellationToken)
|
|
{
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// Built from whatever is there rather than enumerated per combination. Two item types were four cases;
|
|
// three would be eight, and the fourth kind the selector will eventually grow — pinned host keys —
|
|
// would be sixteen.
|
|
var counted = new List<string>(3);
|
|
|
|
if (Hosts.Count > 0)
|
|
{
|
|
counted.Add($"{Hosts.Count} host(s)");
|
|
}
|
|
|
|
if (Keys.Count > 0)
|
|
{
|
|
counted.Add($"{Keys.Count} key(s)");
|
|
}
|
|
|
|
if (Credentials.Count > 0)
|
|
{
|
|
counted.Add($"{Credentials.Count} credential(s)");
|
|
}
|
|
|
|
var contents = string.Join(", ", counted);
|
|
|
|
// "No hosts yet" survives as its own case, because it is the one nudge this line gives: a vault holding
|
|
// keys and credentials and no hosts is set up but unused, and "2 key(s) in Personal." would read as
|
|
// though everything were in order.
|
|
Status = (Hosts.Count, counted.Count) switch
|
|
{
|
|
(0, 0) => "No hosts yet. Add one.",
|
|
(0, _) => $"No hosts yet, and {contents} in {VaultName}.",
|
|
_ => $"{contents} in {VaultName}.",
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the vault into the list, silently.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Separate from <see cref="LoadAsync" /> because every caller except the first load has something
|
|
/// better to say afterwards — a save, a deletion, or a sync report — and a background pass has nothing
|
|
/// to say at all. Rebuilding the list used to repaint the status line unconditionally, which made
|
|
/// "the background pass is quiet" false on the one path that mattered.
|
|
/// </remarks>
|
|
private async Task ReloadAsync(CancellationToken cancellationToken)
|
|
{
|
|
// First, because the four lists below are read across the same set and a vault admitted by the
|
|
// last refresh should appear in the picker on the same pass its items do.
|
|
RebuildTargetVaults();
|
|
|
|
// Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what
|
|
// gets counted under them — so the host reload is the pass that can put both together. Tags are read
|
|
// in the same place and for the same reason: a host row draws a chip per tag, and it can only draw
|
|
// the name if the tag was read first.
|
|
var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
unreadable += await ReloadTagsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// After the hosts, because a tag row says how many wear it.
|
|
RebuildTags();
|
|
|
|
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
|
|
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
|
|
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
|
|
unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// Last, because it reads the host list to work out which pins nothing dials any more.
|
|
unreadable += await ReloadKnownHostsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
UnreadableItems = unreadable;
|
|
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// After all four lists, because the table is a projection of three of them.
|
|
RebuildVaultItems();
|
|
|
|
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
|
|
/// <remarks>
|
|
/// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
|
|
/// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
|
|
/// vault rather than to nothing, so the next Save still has somewhere to go.
|
|
/// </remarks>
|
|
private void RebuildTargetVaults()
|
|
{
|
|
var selectedId = TargetVaultId;
|
|
|
|
TargetVaults.Clear();
|
|
|
|
foreach (var vault in session.ReadableVaults
|
|
.Where(vault => vault.CanWrite)
|
|
.OrderByDescending(vault => vault.IsPersonal)
|
|
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
|
|
{
|
|
TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
|
|
}
|
|
|
|
SelectedTargetVault =
|
|
TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId)
|
|
?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId)
|
|
?? TargetVaults.FirstOrDefault();
|
|
|
|
OnPropertyChanged(nameof(HasVaultChoice));
|
|
}
|
|
|
|
/// <returns>How many hosts would not decrypt.</returns>
|
|
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var selectedId = SelectedHost?.EntityId;
|
|
var unreadable = 0;
|
|
var rows = new List<HostRowViewModel>();
|
|
|
|
// Every vault this session holds a key for, not only the one new items are filed into. A team
|
|
// vault whose hosts never reached this list would make sharing look as though it had not worked.
|
|
var readable = session.ReadableVaults.ToList();
|
|
var several = readable.Count > 1;
|
|
|
|
foreach (var vault in readable)
|
|
{
|
|
var listing = await session.Hosts
|
|
.ListAsync(vault.VaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
// Resolved here, which is why ReloadGroupsAsync and ReloadTagsAsync both run before this: a host
|
|
// resolved against a stale group list would show one port and dial another, and one resolved
|
|
// against a stale tag list would draw a chip that has been renamed.
|
|
rows.AddRange(listing.Items.Select(
|
|
item => new HostRowViewModel(
|
|
item, Resolve(item.Secret), LabelsFor(item.Secret.TagIds), 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.
|
|
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
|
}));
|
|
}
|
|
|
|
Hosts.Clear();
|
|
|
|
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
|
|
// hold a host with the same label and both are shown: which vault it is in is what tells them
|
|
// apart, which is why the row carries the name rather than the list deduplicating.
|
|
foreach (var host in rows
|
|
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
|
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
|
{
|
|
Hosts.Add(host);
|
|
}
|
|
|
|
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
|
|
// under the user.
|
|
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
|
|
|
|
// Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
|
|
// the group rows.
|
|
RebuildGroups();
|
|
RebuildVisibleHosts();
|
|
|
|
return unreadable;
|
|
}
|
|
|
|
/// <returns>How many buckets would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// The selection survives a reload and a reload never invents one, as the key and credential lists do and
|
|
/// for the same reason: it is what the delete button aims at.
|
|
/// </remarks>
|
|
private async Task<int> ReloadObjectStoresAsync(CancellationToken cancellationToken)
|
|
{
|
|
var listing = await session.ObjectStores
|
|
.ListAsync(session.ActiveVaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
var selectedId = SelectedObjectStore?.EntityId;
|
|
|
|
ObjectStores.Clear();
|
|
|
|
foreach (var store in listing.Items
|
|
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
|
|
{
|
|
ObjectStores.Add(new ObjectStoreRowViewModel(store));
|
|
}
|
|
|
|
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
|
|
|
|
return listing.Unreadable;
|
|
}
|
|
|
|
/// <returns>How many snippets would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// No selection to preserve: what a snippet screen selects is its own, and it restores it around this
|
|
/// list changing the way every other screen does.
|
|
/// </remarks>
|
|
private async Task<int> ReloadSnippetsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var listing = await session.Snippets
|
|
.ListAsync(session.ActiveVaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
Snippets.Clear();
|
|
|
|
foreach (var snippet in listing.Items
|
|
.OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture))
|
|
{
|
|
Snippets.Add(new SnippetRowViewModel(snippet));
|
|
}
|
|
|
|
return listing.Unreadable;
|
|
}
|
|
|
|
/// <summary>Stores one snippet, encrypted, and queues it for the server.</summary>
|
|
/// <param name="entityId">The snippet to replace, or null to create one.</param>
|
|
/// <param name="snippet">What to store.</param>
|
|
/// <param name="cancellationToken">Cancellation.</param>
|
|
/// <returns>Whether it was stored; <see langword="false"/> means the reason is in <see cref="Status"/>.</returns>
|
|
/// <remarks>
|
|
/// Here rather than on the screen, so the write goes through the same repository, the same outbox and the
|
|
/// same immediate push as every other save. The screen decides <em>what</em> a snippet is and nothing
|
|
/// else.
|
|
/// </remarks>
|
|
internal async Task<bool> SaveSnippetAsync(
|
|
Guid? entityId,
|
|
SnippetSecret snippet,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(snippet);
|
|
|
|
if (!snippet.TryValidate(out var reason))
|
|
{
|
|
Status = reason;
|
|
return false;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
if (entityId is { } existing)
|
|
{
|
|
await session.Snippets
|
|
.UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
await session.Snippets
|
|
.CreateAsync(session.ActiveVaultId, snippet, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = connection() is null
|
|
? $"Saved '{snippet.Label}'. It will sync when you are online."
|
|
: $"Saved '{snippet.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
return true;
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for one snippet.</summary>
|
|
internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
Status = "That snippet is no longer here, so nothing was deleted.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Deleting…",
|
|
async () =>
|
|
{
|
|
await session.Snippets
|
|
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
Status = $"Deleted '{row.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <returns>How many groups would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The listing is kept rather than projected straight into <see cref="Groups"/>, because a group row
|
|
/// carries how many hosts name it and the hosts have not been read yet when this runs. See
|
|
/// <see cref="RebuildGroups"/>, which is where the two meet.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Two reads, and they cover different vaults on purpose.</b> The editable list — the rows the
|
|
/// sidebar draws headings from and the group editor renames — is the active vault's alone. The
|
|
/// resolution map is every readable vault's.
|
|
/// </para>
|
|
/// <para>
|
|
/// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which
|
|
/// vault it lives in, because rename and delete both need it, and two vaults may hold groups with the
|
|
/// same name, which the one-heading-per-group layout cannot tell apart. Both are worth doing and neither
|
|
/// is a merge's business. Recorded in <c>docs/design-import-gaps.md</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The map could not stay narrow, and that changed with inheritance.</b> While a group was only a
|
|
/// name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost
|
|
/// nothing else — the same thing the sidebar shows for a group that has been deleted. Since a group
|
|
/// began lending a port, a username and a binding, the same omission silently drops all three: that host
|
|
/// would dial 22 as nobody, while the machine it names is on 2222 as <c>deploy</c>, and nothing on
|
|
/// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong
|
|
/// place.
|
|
/// </para>
|
|
/// <para>
|
|
/// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across
|
|
/// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever
|
|
/// asked "what does this id say", which is exactly the question a host's <c>GroupId</c> poses.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var unreadable = 0;
|
|
var resolvable = new Dictionary<Guid, HostGroupSecret>();
|
|
|
|
// Emptied before the loop rather than assigned inside it, because the active vault may not be in
|
|
// the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only
|
|
// ever writes on a match would leave the last readable vault's groups on screen as though they were
|
|
// still this one's.
|
|
groupItems = [];
|
|
|
|
foreach (var vault in session.ReadableVaults)
|
|
{
|
|
var listing = await session.HostGroups
|
|
.ListAsync(vault.VaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
foreach (var group in listing.Items)
|
|
{
|
|
resolvable[group.EntityId] = group.Secret;
|
|
}
|
|
|
|
if (vault.VaultId == session.ActiveVaultId)
|
|
{
|
|
groupItems =
|
|
[.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
|
}
|
|
}
|
|
|
|
groupsById = resolvable;
|
|
|
|
return unreadable;
|
|
}
|
|
|
|
/// <returns>How many tags would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// The same two reads as <see cref="ReloadGroupsAsync"/>, and the same split: the editable list is the
|
|
/// active vault's, the resolution map is every readable vault's. A tag id that does not resolve simply
|
|
/// draws no chip, so the cost of a narrow map here is a host that looks untagged rather than one that
|
|
/// dials the wrong port — cheaper than the group case, and no cheaper to get right.
|
|
/// </remarks>
|
|
private async Task<int> ReloadTagsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var unreadable = 0;
|
|
var resolvable = new Dictionary<Guid, TagSecret>();
|
|
|
|
tagItems = [];
|
|
|
|
foreach (var vault in session.ReadableVaults)
|
|
{
|
|
var listing = await session.Tags
|
|
.ListAsync(vault.VaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
foreach (var tag in listing.Items)
|
|
{
|
|
resolvable[tag.EntityId] = tag.Secret;
|
|
}
|
|
|
|
if (vault.VaultId == session.ActiveVaultId)
|
|
{
|
|
tagItems = [.. listing.Items.OrderBy(tag => tag.Secret.Label, StringComparer.CurrentCulture)];
|
|
}
|
|
}
|
|
|
|
tagsById = resolvable;
|
|
|
|
return unreadable;
|
|
}
|
|
|
|
/// <summary>Refills <see cref="Tags"/>, counting the hosts wearing each.</summary>
|
|
/// <remarks>
|
|
/// Counted over <see cref="Hosts"/>, which spans every readable vault, while the rows themselves are
|
|
/// the active vault's. That asymmetry is deliberate and is the same one the delete warning needs: a tag
|
|
/// worn by a teammate's host is still worn, and a count that ignored those would tell somebody a tag
|
|
/// was unused just before they deleted it out from under twenty machines.
|
|
/// </remarks>
|
|
private void RebuildTags()
|
|
{
|
|
var selectedId = SelectedTag?.EntityId;
|
|
|
|
Tags.Clear();
|
|
|
|
foreach (var tag in tagItems)
|
|
{
|
|
Tags.Add(new TagRowViewModel(
|
|
tag, Hosts.Count(row => row.Host.TagIds.Contains(tag.EntityId))));
|
|
}
|
|
|
|
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
|
|
/// authenticate.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one place this view model turns a stored host into a dialled one. A host may leave any of the
|
|
/// three unset and take its group's, so reading <c>host.Port</c> or <c>host.SshKeyId</c> directly
|
|
/// answers "what did the user type into this host" and not "what happens when this is connected" — and
|
|
/// almost everything on screen wants the second question. See <see cref="HostInheritance"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Reads <see cref="groupsById"/>, which is refilled by <see cref="ReloadGroupsAsync"/> before the hosts
|
|
/// are read. That ordering is not incidental: a host resolved against a stale group list would show one
|
|
/// port and dial another.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById);
|
|
|
|
/// <summary>
|
|
/// The names behind a host's tag ids, sorted for display, skipping any that do not resolve.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Sorted by name rather than kept in the set's own order. <see cref="TagSet"/> sorts by id, which is a
|
|
/// UUIDv7 and therefore by when the tag was created — an order that means nothing on screen and changes
|
|
/// what a row looks like depending on which machine made which tag first.
|
|
/// </remarks>
|
|
private IReadOnlyList<string> LabelsFor(TagSet tagIds) =>
|
|
[
|
|
.. tagIds
|
|
.Where(tagsById.ContainsKey)
|
|
.Select(id => tagsById[id].Label)
|
|
.OrderBy(label => label, StringComparer.CurrentCulture),
|
|
];
|
|
|
|
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
|
|
private void RebuildGroups()
|
|
{
|
|
var selectedId = SelectedGroup?.EntityId;
|
|
|
|
Groups.Clear();
|
|
|
|
foreach (var group in groupItems)
|
|
{
|
|
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId);
|
|
|
|
Groups.Add(new HostGroupRowViewModel(group, count));
|
|
}
|
|
|
|
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
|
|
// RENAME and DELETE aim at, and a background sync that picked a group would point them at one nobody
|
|
// chose.
|
|
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
|
|
|
|
OnPropertyChanged(nameof(HasGroups));
|
|
}
|
|
|
|
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
|
|
/// <remarks>
|
|
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
|
|
/// keeps this method from undoing its own caller. <c>ListBox.SelectedItem</c> is two-way bound to
|
|
/// <see cref="SelectedHost"/>, so <c>VisibleHosts.Clear()</c> is a <c>Reset</c> the list reacts to by
|
|
/// nulling its selection, and the binding writes that null straight back — silently, and before this
|
|
/// method's own refill has a chance to matter. <see cref="ReloadHostsAsync"/> restores the selection and
|
|
/// calls this immediately after, which used to mean every reload undid what it had just restored, and
|
|
/// every keystroke in the filter box did the same.
|
|
/// </remarks>
|
|
private void RebuildVisibleHosts()
|
|
{
|
|
var selected = SelectedHost;
|
|
|
|
VisibleHosts.Clear();
|
|
|
|
foreach (var host in Hosts.Where(Matches))
|
|
{
|
|
VisibleHosts.Add(host);
|
|
}
|
|
|
|
RebuildSidebarRows();
|
|
|
|
// Restored when it still matches, and explicitly cleared when it does not — rather than left alone
|
|
// and trusted to whatever a live SelectedItem binding happens to do about it. A filter that hides
|
|
// the selected host has to mean nothing is selected: Connect, Edit and Delete all read this
|
|
// property directly, and a host that is not on screen is not one any of them should act on.
|
|
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
|
|
|
|
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
|
|
// above is a null that has to be overwritten rather than read.
|
|
SelectedSidebarRow = SelectedHost;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Lays the visible hosts out under their group headings.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>No groups means no headings.</b> The sidebar of a vault nobody has filed anything in is the list it
|
|
/// always was, which is what makes this feature cost nothing to ignore.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A host whose group has been deleted falls under the ungrouped heading</b> rather than disappearing
|
|
/// or keeping an empty heading of its own. The reference is allowed to dangle — see
|
|
/// <see cref="HostSecret.GroupId"/> for why deleting a group deliberately does not rewrite the hosts in
|
|
/// it — so "the group this names is not here" and "this names no group" have to look the same, because to
|
|
/// the user they are the same thing.
|
|
/// </para>
|
|
/// <para>
|
|
/// An empty group still gets its heading, and a group emptied by the <em>filter</em> does not. The first
|
|
/// is a thing the user made and can file hosts into; the second is an absence of search results, and a
|
|
/// heading with nothing under it would read as a group that had lost its contents.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void RebuildSidebarRows()
|
|
{
|
|
SidebarRows.Clear();
|
|
|
|
if (Groups.Count == 0)
|
|
{
|
|
foreach (var host in VisibleHosts)
|
|
{
|
|
SidebarRows.Add(host);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
|
|
|
foreach (var group in Groups)
|
|
{
|
|
AddSection(group.EntityId, group.Label, host => host.Host.GroupId == group.EntityId);
|
|
}
|
|
|
|
AddSection(
|
|
null,
|
|
"UNGROUPED",
|
|
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
|
onlyWhenOccupied: true);
|
|
|
|
void AddSection(
|
|
Guid? groupId,
|
|
string label,
|
|
Func<HostRowViewModel, bool> belongs,
|
|
bool onlyWhenOccupied = false)
|
|
{
|
|
var members = VisibleHosts.Where(belongs).ToArray();
|
|
|
|
if (onlyWhenOccupied && members.Length == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var expanded = !collapsedGroups.Contains(groupId ?? Guid.Empty);
|
|
|
|
SidebarRows.Add(new SidebarGroupHeader(groupId, label, members.Length, expanded));
|
|
|
|
if (!expanded)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var member in members)
|
|
{
|
|
SidebarRows.Add(member);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <summary>Folds one group's hosts away, or brings them back.</summary>
|
|
/// <remarks>
|
|
/// Keyed on the group id in a set of the folded ones rather than on a flag on the row, because the rows
|
|
/// are rebuilt from scratch on every filter keystroke and every background sync — a flag would be
|
|
/// forgotten a minute after it was set. The ungrouped heading uses <see cref="Guid.Empty"/>, which is not
|
|
/// a legal group id: <c>HostSecret.TryValidate</c> refuses one.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void ToggleGroup(SidebarGroupHeader? header)
|
|
{
|
|
if (header is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var key = header.GroupId ?? Guid.Empty;
|
|
|
|
if (!collapsedGroups.Remove(key))
|
|
{
|
|
collapsedGroups.Add(key);
|
|
}
|
|
|
|
RebuildSidebarRows();
|
|
SelectedSidebarRow = SelectedHost;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Files one host under one group, or under none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// What dragging a row onto a heading does, and the only thing in this application that changes a host
|
|
/// without opening the editor. That is the justification for it existing at all: filing thirty imported
|
|
/// machines meant thirty rounds of open, pick, save, and the field being changed is the one field of a
|
|
/// host that is about arrangement rather than about the machine.
|
|
/// </para>
|
|
/// <para>
|
|
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
|
|
/// drop is a gesture on the list, not on the form: rewriting the item under a half-typed edit of the same
|
|
/// host would be a save the user never asked for, and one they would then be unable to cancel.
|
|
/// </para>
|
|
/// <para>
|
|
/// A group id that is not in this vault is not refused — it is treated as no group at all, which is what
|
|
/// the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
|
|
/// method that takes one cancels the previous execution's token on every invocation, and two drops in
|
|
/// quick succession are two writes rather than one superseding the other. This is one row's one field
|
|
/// and it is over in a moment.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="request">The host to move, and where to.</param>
|
|
[RelayCommand]
|
|
private async Task MoveHostToGroupAsync(HostGroupMove? request)
|
|
{
|
|
if (request is not { Host: { } row })
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
// The same refusal editing makes, and for the same reason: re-encoding an item a newer client
|
|
// wrote would drop the fields this build has no concept of.
|
|
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
|
|
return;
|
|
}
|
|
|
|
if (IsEditing)
|
|
{
|
|
Status = "Finish or cancel the host you are editing first.";
|
|
return;
|
|
}
|
|
|
|
Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted)
|
|
? wanted
|
|
: null;
|
|
|
|
if (row.Host.GroupId == target)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var moved = row.Host with { GroupId = target };
|
|
var name = target is null
|
|
? "no group"
|
|
: Groups.First(group => group.EntityId == target).Label;
|
|
|
|
await RunAsync(
|
|
$"Filing {row.Label} under {name}…",
|
|
async () =>
|
|
{
|
|
await session.Hosts
|
|
.UpdateAsync(row.VaultId, row.EntityId, moved, CancellationToken.None)
|
|
.ConfigureAwait(true);
|
|
|
|
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
|
|
|
// Re-found rather than kept: the reload replaces every row, so the object that was dragged is
|
|
// no longer the one in the list, and leaving the selection pointing at it would light nothing.
|
|
SelectedHost = Hosts.FirstOrDefault(candidate => candidate.EntityId == row.EntityId);
|
|
|
|
Status = target is null
|
|
? $"'{row.Label}' is no longer in a group."
|
|
: $"Filed '{row.Label}' under '{name}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
// Pushed straight away, as a save from the editor is: this is a save from the editor, minus the
|
|
// editor.
|
|
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
|
|
/// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
|
|
/// wrote down about a machine is often the only place its purpose is recorded.
|
|
/// </remarks>
|
|
private bool Matches(HostRowViewModel row)
|
|
{
|
|
var filter = HostFilter.Trim();
|
|
|
|
if (filter.Length == 0)
|
|
{
|
|
return true;
|
|
}
|
|
|
|
return Contains(row.Label) || Contains(row.Address) || Contains(row.Host.Notes);
|
|
|
|
bool Contains(string? value) =>
|
|
value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
|
|
}
|
|
|
|
/// <returns>How many keys would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
|
/// <see cref="DeleteKey" /> aims at, and a list that picked a row on every background sync would point
|
|
/// that button at a key nobody chose.
|
|
/// </remarks>
|
|
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
|
{
|
|
var selectedId = SelectedKey?.EntityId;
|
|
var unreadable = 0;
|
|
var rows = new List<SshKeyRowViewModel>();
|
|
|
|
foreach (var vault in session.ReadableVaults)
|
|
{
|
|
var listing = await session.SshKeys
|
|
.ListAsync(vault.VaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
rows.AddRange(listing.Items.Select(
|
|
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
|
|
}
|
|
|
|
Keys.Clear();
|
|
|
|
foreach (var key in rows
|
|
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
|
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
|
{
|
|
Keys.Add(key);
|
|
}
|
|
|
|
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);
|
|
|
|
return unreadable;
|
|
}
|
|
|
|
/// <returns>How many credentials would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
|
|
/// as the key list and matters more here. <see cref="DeleteCredential" /> reads the selection, so a list
|
|
/// that fell back to its first row would point the deletion — and the question in front of it — at a
|
|
/// password nobody chose.
|
|
/// </remarks>
|
|
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var selectedId = SelectedCredential?.EntityId;
|
|
var unreadable = 0;
|
|
var rows = new List<CredentialRowViewModel>();
|
|
|
|
foreach (var vault in session.ReadableVaults)
|
|
{
|
|
var listing = await session.Credentials
|
|
.ListAsync(vault.VaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
rows.AddRange(listing.Items.Select(
|
|
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
|
|
}
|
|
|
|
Credentials.Clear();
|
|
|
|
foreach (var credential in rows
|
|
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
|
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
|
{
|
|
Credentials.Add(credential);
|
|
}
|
|
|
|
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId);
|
|
|
|
return unreadable;
|
|
}
|
|
|
|
/// <returns>How many pins would not decrypt.</returns>
|
|
/// <remarks>
|
|
/// Read through the repository rather than through <c>VaultKnownHostStore</c>, which holds a snapshot
|
|
/// shaped for the SSH handshake — one pin per endpoint, deduplicated, and with no entity ids. This list
|
|
/// has to show duplicates, because a duplicate is one of the things worth seeing.
|
|
/// </remarks>
|
|
private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var selectedId = SelectedKnownHost?.EntityId;
|
|
var unreadable = 0;
|
|
var rows = new List<KnownHostRowViewModel>();
|
|
|
|
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
|
|
// hundred scans of the host list on every background sync.
|
|
var dialled = Hosts
|
|
.Select(host => Endpoint(host.Host.Hostname, Resolve(host.Host).Port.Value))
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
|
// Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
|
|
// the active vault alone. The difference is deliberate and is stated in the README: a pin in a
|
|
// team vault is something a teammate can write, and letting it answer for a host in somebody's
|
|
// personal vault would let one member suppress another's first-contact prompt. Showing them is
|
|
// safe and is the only way somebody can see what their team has trusted.
|
|
foreach (var vault in session.ReadableVaults)
|
|
{
|
|
var listing = await session.KnownHosts
|
|
.ListAsync(vault.VaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
unreadable += listing.Unreadable;
|
|
|
|
rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel(
|
|
item,
|
|
dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)),
|
|
vault.VaultId,
|
|
vault.Name)));
|
|
}
|
|
|
|
KnownHostPins.Clear();
|
|
|
|
foreach (var pin in rows
|
|
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
|
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
|
{
|
|
KnownHostPins.Add(pin);
|
|
}
|
|
|
|
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId);
|
|
|
|
return unreadable;
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Case-insensitively, because a host name is, and <c>KnownHostIdentity</c> keys the store the same way.
|
|
/// A pin written as <c>DB.internal</c> and a host saved as <c>db.internal</c> are the same machine, and a
|
|
/// list that called one of them unused would be inviting somebody to delete trust they rely on.
|
|
/// </remarks>
|
|
private static string Endpoint(string host, int port) =>
|
|
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");
|
|
|
|
/// <summary>Runs a synchronisation pass, if this machine can reach a server.</summary>
|
|
/// <remarks>
|
|
/// The offline branch is inside <see cref="RunAsync" /> rather than in front of it, because getting
|
|
/// online is now part of what this button does: resuming a remembered sign-in is a network round trip
|
|
/// and belongs under the same busy flag as the pass it leads to.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task SyncAsync(CancellationToken cancellationToken)
|
|
{
|
|
await RunAsync(
|
|
"Synchronising…",
|
|
async () =>
|
|
{
|
|
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
|
{
|
|
LastSyncFailed = true;
|
|
Status = "Offline. Changes are queued and will be sent as soon as this machine "
|
|
+ "can reach the server again.";
|
|
return;
|
|
}
|
|
|
|
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
|
|
|
// Null means a background pass held the gate. Saying so beats reporting a sync that this
|
|
// press did not perform.
|
|
Status = report is null
|
|
? "A synchronisation is already running."
|
|
: Describe(report);
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Finds a server to sync against, getting this machine online if it is not.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The handler is asked even when a connection is already held, which looks redundant and is not: the
|
|
/// shell is the thing that persists the refresh token so a later launch can resume, and identity
|
|
/// providers rotate that token on every refresh. Asking once per pass is what keeps the remembered
|
|
/// sign-in current without an event and without this view model knowing what a token is.
|
|
/// </remarks>
|
|
private Task<IVaultServer?> ResolveServerAsync(CancellationToken cancellationToken) =>
|
|
reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken);
|
|
|
|
/// <summary>
|
|
/// Starts syncing in the background until the vault is disposed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Explicit rather than started from the constructor, so that a test can drive
|
|
/// <see cref="AutoSyncAsync" /> a pass at a time instead of racing a timer.
|
|
/// </remarks>
|
|
internal void StartAutoSync()
|
|
{
|
|
if (autoSync is not null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
autoSync = new CancellationTokenSource();
|
|
autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token);
|
|
}
|
|
|
|
/// <summary>
|
|
/// One background synchronisation pass, which stays out of the way.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Deliberately not routed through <see cref="RunAsync" />. That would raise the busy flag every
|
|
/// interval — disabling Connect and Save for the duration — and repaint the status line with
|
|
/// "Synchronising…" while the user was reading something else. A background pass that makes the
|
|
/// application feel intermittently broken is worse than a Sync button.
|
|
/// </para>
|
|
/// <para>
|
|
/// So it is silent unless it has something to say: the status line changes only when the pass actually
|
|
/// moved an item or produced something needing attention. It also yields to the user — a pass is
|
|
/// skipped outright while a command is running, rather than queueing behind it.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// One background synchronisation pass, run whether or not a command is in flight.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The same quiet pass as <see cref="AutoSyncAsync" /> without the one thing that made it useless at
|
|
/// the moment it matters most. The loop is started from inside the unlock command, so the busy flag a
|
|
/// timed pass yields to is raised by the very command that opened the vault — and the pass on open
|
|
/// therefore never ran, silently, putting the first synchronisation a full minute after unlock.
|
|
/// </para>
|
|
/// <para>
|
|
/// Yielding is right for every later pass, because by then a busy flag means a person is doing
|
|
/// something. It is wrong for this one, because the thing it would be yielding to is the unlock.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task SyncOnOpenAsync(CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
|
|
|
if (report is null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it
|
|
// gets the treatment the catch below gives a total failure, the fact kept and the message
|
|
// swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was
|
|
// reading, once a minute, with the name of a vault it could not reach. Pressing Sync still
|
|
// names the vault and the reason, because somebody who pressed it is waiting for an answer.
|
|
if (IsWorthReporting(report))
|
|
{
|
|
Status = Describe(report);
|
|
}
|
|
|
|
await PruneLogsIfDueAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Locking, or closing.
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// The message is swallowed on purpose, and this is the one place in the view model where that
|
|
// is right: a laptop that has been closed all afternoon would otherwise replace whatever the
|
|
// user was reading with a socket error once a minute. Pressing Sync still reports the reason.
|
|
//
|
|
// The *fact* is not swallowed, and that is the half that used to be missing. Recording it is
|
|
// what lets the titlebar stop claiming to be up to date with a server it cannot reach.
|
|
LastSyncFailed = true;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The gate is shared with the manual command, so a press and a tick can never overlap. Taken with a
|
|
/// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
|
|
/// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
|
|
/// </remarks>
|
|
private async Task<IReadOnlyList<VaultSyncReport>?> SyncOnceAsync(
|
|
ISyncApi api,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
try
|
|
{
|
|
// Every vault this session can read, not only the one new items are filed into. A team's
|
|
// vault that never synced would show its hosts exactly once — at the unlock that first
|
|
// pulled it — and then quietly stop, which reads as the feature not working.
|
|
var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true);
|
|
|
|
// Not unconditionally false, which it was while a pass was one vault and a failure was an
|
|
// exception. A failure is now a report — one unreachable team vault must not stop the others
|
|
// syncing — so clearing the flag here regardless would light the titlebar green over a vault
|
|
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
|
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
|
|
// snapshot rather than reading per lookup — so a pass that pulled a pin has to hand it over here,
|
|
// or a host a colleague approved stays a first-contact prompt until the next unlock.
|
|
await knownHosts.RefreshAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
return report;
|
|
}
|
|
finally
|
|
{
|
|
syncGate.Release();
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <c>ConfigureAwait(true)</c> throughout, and that is load-bearing rather than habit: the loop is
|
|
/// started from the UI thread, so every continuation returns to it and the observable collections
|
|
/// <see cref="LoadAsync" /> rebuilds are still only ever touched from one thread. A
|
|
/// <c>ConfigureAwait(false)</c> here would mutate them from a timer thread, which Avalonia will
|
|
/// eventually notice in a way that looks like a rendering bug.
|
|
/// </remarks>
|
|
private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken)
|
|
{
|
|
using var timer = new PeriodicTimer(AutoSyncInterval);
|
|
|
|
try
|
|
{
|
|
// A pass on open, before the first tick. A vault edited on another machine should be current by
|
|
// the time the user has finished reading the list, not a minute afterwards.
|
|
//
|
|
// Deliberately not through AutoSyncAsync, and this is not a shortcut. This loop is started from
|
|
// inside the unlock command, so the busy flag that pass yields to is raised by the very command
|
|
// that opened the vault — and the pass on open therefore never ran at all. It was a silent
|
|
// no-op that put the first synchronisation a full minute after unlock, on the launch where
|
|
// being current matters most. The later passes keep the check: by then, a busy flag means a
|
|
// user is doing something.
|
|
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
|
{
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Locking, or closing.
|
|
}
|
|
}
|
|
|
|
/// <summary>Shows one kind of item, if nothing is being edited.</summary>
|
|
/// <remarks>
|
|
/// Takes the section rather than there being one command per kind, so a third kind is an enum member and
|
|
/// a button and nothing else.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void ShowSection(VaultSection target)
|
|
{
|
|
// Before the editor check, not after. Asking for the section already showing is not a request to leave
|
|
// an editor, and refusing it would scold somebody for clicking where they already are.
|
|
if (target == Section)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Section = target;
|
|
|
|
// Cleared rather than set to the section's name. The status line is shared with the account bar and
|
|
// carries the result of the last thing that happened; "Hosts." would push a sync report or a save
|
|
// confirmation off it to say something the selector is already showing.
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Raises the sheet that asks whether the thing being added is a host or a group.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two things behind one <c>+</c>, which is the design's arrangement and is also the honest one: a
|
|
/// phone has room for one floating button, and "add" on this screen has genuinely been two operations
|
|
/// since groups existed. The desktop asks the same question by having two buttons in two panels, which
|
|
/// is what a 1280-pixel window can afford.
|
|
/// </para>
|
|
/// <para>
|
|
/// Refuses while an editor is open rather than stacking on top of it. The button that raises this is
|
|
/// hidden in that state — see <see cref="AnEditorIsOpen"/> — so this is the guard for the path the
|
|
/// button does not control, which is a command invoked from anywhere else.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void OpenAddSheet()
|
|
{
|
|
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsAddSheetOpen = true;
|
|
}
|
|
|
|
/// <summary>Lowers the add sheet without choosing anything.</summary>
|
|
[RelayCommand]
|
|
private void CloseAddSheet() => IsAddSheetOpen = false;
|
|
|
|
/// <summary>Starts a new host.</summary>
|
|
[RelayCommand]
|
|
private void NewHost()
|
|
{
|
|
IsAddSheetOpen = false;
|
|
|
|
if (AHostEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
editingEntityId = null;
|
|
editingHostVaultId = TargetVaultId;
|
|
EditorLabel = string.Empty;
|
|
EditorHostname = string.Empty;
|
|
|
|
// 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;
|
|
editorTagIds = TagSet.Empty;
|
|
EditorNewTag = string.Empty;
|
|
BuildTagChoices();
|
|
|
|
// 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. 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.";
|
|
}
|
|
|
|
/// <summary>Opens the selected host for editing.</summary>
|
|
[RelayCommand]
|
|
private void EditSelectedHost()
|
|
{
|
|
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
// Re-encoding would drop fields this build has no concept of, so the honest answer is to
|
|
// refuse rather than to silently lose a colleague's data.
|
|
Status = "This host was written by a newer version of DodoSSH. Update before editing it.";
|
|
return;
|
|
}
|
|
|
|
editingEntityId = row.EntityId;
|
|
editingHostVaultId = row.VaultId;
|
|
EditorLabel = row.Host.Label;
|
|
EditorHostname = row.Host.Hostname;
|
|
|
|
// 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;
|
|
editorTagIds = row.Host.TagIds;
|
|
EditorNewTag = string.Empty;
|
|
BuildTagChoices();
|
|
|
|
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}.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens whichever editor the selected row belongs to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// One button over three kinds, because the table is one table. It delegates rather than duplicating:
|
|
/// each kind's own command already knows how to refuse a read-only item and how to load an editor
|
|
/// without a second decryption, and a merged copy of that would be a second place to get it wrong.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void EditSelectedItem()
|
|
{
|
|
switch (SelectedVaultItem?.Kind)
|
|
{
|
|
case VaultItemKind.Key:
|
|
EditSelectedKeyCommand.Execute(null);
|
|
break;
|
|
|
|
case VaultItemKind.Credential:
|
|
EditSelectedCredentialCommand.Execute(null);
|
|
break;
|
|
|
|
case VaultItemKind.ObjectStore:
|
|
EditObjectStoreCommand.Execute(null);
|
|
break;
|
|
|
|
case VaultItemKind.Tag:
|
|
EditTagCommand.Execute(null);
|
|
break;
|
|
|
|
default:
|
|
// A pin has no editor. Its button is Forget, and it is elsewhere on the pane.
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>Asks about deleting whatever the selected row is.</summary>
|
|
/// <remarks>
|
|
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
|
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
|
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void DeleteSelectedItem()
|
|
{
|
|
switch (SelectedVaultItem?.Kind)
|
|
{
|
|
case VaultItemKind.Key:
|
|
DeleteKeyCommand.Execute(null);
|
|
break;
|
|
|
|
case VaultItemKind.Credential:
|
|
DeleteCredentialCommand.Execute(null);
|
|
break;
|
|
|
|
case VaultItemKind.ObjectStore:
|
|
DeleteObjectStoreCommand.Execute(null);
|
|
break;
|
|
|
|
case VaultItemKind.Tag:
|
|
DeleteTagCommand.Execute(null);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>Folds the host list away, or brings it back.</summary>
|
|
[RelayCommand]
|
|
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
|
|
|
|
/// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One box and one button for both, because a group is one field: a separate "rename" form would be the
|
|
/// same text box with a different title. <see cref="EditingGroupId"/> is what decides which of the two
|
|
/// this is, and it is set by <see cref="EditGroup"/> and cleared by everything else.
|
|
/// </para>
|
|
/// <para>
|
|
/// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
|
|
/// <em>wrong</em> — hosts point at ids, so the two are genuinely separate folders — and refusing the
|
|
/// second one would mean a name somebody chose on another machine could block one they choose here, at
|
|
/// the next sync, with the rename already saved.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task SaveGroupAsync(CancellationToken cancellationToken)
|
|
{
|
|
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))
|
|
{
|
|
Status = reason;
|
|
return;
|
|
}
|
|
|
|
var renaming = EditingGroupId;
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
if (renaming is { } entityId)
|
|
{
|
|
await session.HostGroups
|
|
.UpdateAsync(session.ActiveVaultId, entityId, group, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
await session.HostGroups
|
|
.CreateAsync(session.ActiveVaultId, group, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
ClearGroupEditor();
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = renaming is null ? $"Added the group '{group.Label}'." : $"Saved '{group.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Loads the selected group's name into the box, so saving renames it.</summary>
|
|
[RelayCommand]
|
|
private void EditGroup()
|
|
{
|
|
if (SelectedGroup is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
Status = "This group was written by a newer version of DodoSSH. Update before editing it.";
|
|
return;
|
|
}
|
|
|
|
EditingGroupId = row.EntityId;
|
|
GroupEditorLabel = 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);
|
|
|
|
IsEditingGroup = true;
|
|
Status = $"Editing {row.Label}.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens a group's editor from its heading in the host list.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The phone's only route to <see cref="EditGroup"/>, and it exists because there is no other. The
|
|
/// desktop reaches the group editor through the groups panel, which selects a
|
|
/// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
|
|
/// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
|
|
/// not a thing to be selected. So the heading needs a button, and the button needs a command that takes
|
|
/// the header rather than the selection.
|
|
/// </para>
|
|
/// <para>
|
|
/// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
|
|
/// <c>+</c> that adds hosts with no way to correct one — and worse here, because a group's defaults are
|
|
/// inherited: getting one wrong is wrong for every host beneath it at once.
|
|
/// </para>
|
|
/// <para>
|
|
/// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a
|
|
/// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one
|
|
/// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than
|
|
/// opening an editor on nothing.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void EditGroupFromHeading(SidebarGroupHeader? header)
|
|
{
|
|
if (header?.GroupId is not { } groupId
|
|
|| Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
SelectedGroup = row;
|
|
EditGroupCommand.Execute(null);
|
|
}
|
|
|
|
/// <summary>Starts a new group.</summary>
|
|
/// <remarks>
|
|
/// The desktop never needed this command: its group editor is a bar that is always on screen, so
|
|
/// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its
|
|
/// editor is a card that has to be raised — and raising it from a stale state would offer the last
|
|
/// group's default key to the new one without anybody choosing it, which is what
|
|
/// <see cref="ClearGroupEditor"/> prevents.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void NewGroup()
|
|
{
|
|
IsAddSheetOpen = false;
|
|
|
|
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
ClearGroupEditor();
|
|
|
|
IsEditingGroup = true;
|
|
Status = "Adding a group.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the group editor has to be dealt with before another editor opens.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
|
|
/// It answers only for the phone, where the group editor is a raised card. On the desktop the bar is
|
|
/// always present and <see cref="IsEditingGroup"/> is never set by anything the user can reach, so this
|
|
/// is false there and nothing is refused.
|
|
/// </remarks>
|
|
private bool AGroupEditorIsInTheWay()
|
|
{
|
|
if (IsEditingGroup)
|
|
{
|
|
Status = "Finish or cancel the group you are editing first.";
|
|
}
|
|
|
|
return IsEditingGroup;
|
|
}
|
|
|
|
/// <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;
|
|
IsEditingGroup = false;
|
|
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()
|
|
{
|
|
ClearGroupEditor();
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <summary>Asks whether the selected group should go.</summary>
|
|
/// <remarks>
|
|
/// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
|
|
/// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
|
|
/// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
|
|
/// to move, and that none of them are going anywhere else.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void DeleteGroup()
|
|
{
|
|
if (SelectedGroup is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
DeletionTarget.Group,
|
|
row.EntityId,
|
|
$"Delete the group '{row.Label}'?",
|
|
HowFarADeletionGoes("The group"),
|
|
row.HostCount switch
|
|
{
|
|
0 => string.Empty,
|
|
1 => "1 host is filed under it. The host stays; it moves to UNGROUPED.",
|
|
_ => $"{row.HostCount} hosts are filed under it. They stay; they move to UNGROUPED.",
|
|
});
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for the group that was agreed to.</summary>
|
|
private async Task DeleteGroupNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
Status = "That group is no longer here, so nothing was deleted.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Deleting…",
|
|
async () =>
|
|
{
|
|
await session.HostGroups
|
|
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
if (EditingGroupId == entityId)
|
|
{
|
|
EditingGroupId = null;
|
|
GroupEditorLabel = string.Empty;
|
|
}
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
Status = $"Deleted the group '{row.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Abandons the editor.</summary>
|
|
[RelayCommand]
|
|
private void CancelEdit()
|
|
{
|
|
IsEditing = false;
|
|
editingEntityId = null;
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
|
|
[RelayCommand]
|
|
private async Task SaveHostAsync(CancellationToken cancellationToken)
|
|
{
|
|
var host = BuildHost();
|
|
|
|
if (!host.TryValidate(out var error))
|
|
{
|
|
Status = error;
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
if (editingEntityId is { } entityId)
|
|
{
|
|
await session.Hosts
|
|
.UpdateAsync(editingHostVaultId, entityId, host, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
editingEntityId = await session.Hosts
|
|
.CreateAsync(editingHostVaultId, host, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
IsEditing = false;
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
|
editingEntityId = null;
|
|
|
|
Status = connection() is null
|
|
? $"Saved '{host.Label}'. It will sync when you are online."
|
|
: $"Saved '{host.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
// Pushed now rather than at the next tick. A change the user just made is the one they are most
|
|
// likely to be about to look for on another machine.
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores several hosts at once, the way one save does.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
|
|
/// through the same repository, the same outbox and the same automatic push as saving one — the import
|
|
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host to be
|
|
/// written and no second place for the sync wiring to be forgotten.
|
|
/// </para>
|
|
/// <para>
|
|
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
|
/// thirty rebuilds of the host list and thirty sync passes, which on a slow link is minutes of the
|
|
/// window doing nothing visible.
|
|
/// </para>
|
|
/// <para>
|
|
/// A host that fails validation is skipped and counted rather than aborting the batch. Twenty-nine good
|
|
/// hosts thrown away because the thirtieth had no hostname is not what anybody wants from an import.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal async Task<int> ImportHostsAsync(
|
|
IReadOnlyList<HostSecret> hosts,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(hosts);
|
|
|
|
var imported = 0;
|
|
var refused = 0;
|
|
|
|
await RunAsync(
|
|
hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…",
|
|
async () =>
|
|
{
|
|
foreach (var host in hosts)
|
|
{
|
|
if (!host.TryValidate(out _))
|
|
{
|
|
refused++;
|
|
continue;
|
|
}
|
|
|
|
await session.Hosts
|
|
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
imported++;
|
|
}
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
|
|
|
|
Status = connection() is null
|
|
? $"Imported {imported} host(s). They will sync when you are online.{refusals}"
|
|
: $"Imported {imported} host(s).{refusals}";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
return imported;
|
|
}
|
|
|
|
/// <summary>Asks whether the selected host should go.</summary>
|
|
/// <remarks>
|
|
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
|
/// not close one — a session outlives the row that opened it, exactly as it outlives a lock. Somebody
|
|
/// deleting a machine they are still working on should know that is what they have done.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void DeleteHost()
|
|
{
|
|
if (SelectedHost is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
DeletionTarget.Host,
|
|
row.EntityId,
|
|
$"Delete the host '{row.Label}'?",
|
|
HowFarADeletionGoes("The host and everything saved about it"),
|
|
row.IsConnected
|
|
? "A terminal is open on this host. It stays open — deleting the host does not close it, and "
|
|
+ "nothing will reopen it afterwards."
|
|
: string.Empty);
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for the host that was agreed to.</summary>
|
|
private async Task DeleteHostNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
// Gone between the question and the answer — a sync that pulled somebody else's deletion is the
|
|
// realistic way. Saying so beats a silent no-op under a card that has just been agreed to.
|
|
Status = "That host is no longer here, so nothing was deleted.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Deleting…",
|
|
async () =>
|
|
{
|
|
await session.Hosts
|
|
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
Status = $"Deleted '{row.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
// As with saving: a tombstone is worth pushing straight away, so the item does not reappear on
|
|
// another machine that syncs before the next tick.
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Starts a new SSH key.</summary>
|
|
[RelayCommand]
|
|
private void NewKey()
|
|
{
|
|
if (AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Section = VaultSection.Keys;
|
|
editingKeyId = null;
|
|
editingKeyVaultId = TargetVaultId;
|
|
ClearKeyEditor();
|
|
IsEditingKey = true;
|
|
Status = "Adding an SSH key.";
|
|
}
|
|
|
|
/// <summary>Opens the selected key for editing.</summary>
|
|
/// <remarks>
|
|
/// The private key is loaded into the editor, which is the only way an edit can preserve it: the
|
|
/// codec has no notion of a partial update, so saving re-encodes every field.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void EditSelectedKey()
|
|
{
|
|
if (SelectedKey is not { } row || AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
Status = "This key was written by a newer version of DodoSSH. Update before editing it.";
|
|
return;
|
|
}
|
|
|
|
Section = VaultSection.Keys;
|
|
editingKeyId = row.EntityId;
|
|
editingKeyVaultId = row.VaultId;
|
|
KeyEditorLabel = row.Key.Label;
|
|
KeyEditorPrivateKey = row.Key.PrivateKeyPem;
|
|
KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
|
|
KeyEditorPublicKey = row.Key.PublicKey ?? string.Empty;
|
|
KeyEditorNotes = row.Key.Notes ?? string.Empty;
|
|
IsEditingKey = true;
|
|
Status = $"Editing {row.Label}.";
|
|
}
|
|
|
|
/// <summary>Opens the form in front of generating a key.</summary>
|
|
[RelayCommand]
|
|
private void NewGeneratedKey()
|
|
{
|
|
if (AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Section = VaultSection.Keys;
|
|
GenerateComment = $"{Environment.UserName}@{Environment.MachineName}";
|
|
GenerateAlgorithm = SshKeyAlgorithm.Ed25519;
|
|
IsGeneratingKey = true;
|
|
Status = "Generating a new SSH key.";
|
|
}
|
|
|
|
/// <summary>Chooses which kind of key to make.</summary>
|
|
/// <remarks>
|
|
/// A command and two buttons rather than a selector bound to <see cref="GenerateAlgorithm"/>, which is
|
|
/// the idiom the category rail already uses and for the same reason: a selector moves its own highlight
|
|
/// before anything here can decide, so it can end up showing a choice that was not made.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void ChooseKeyAlgorithm(SshKeyAlgorithm algorithm) => GenerateAlgorithm = algorithm;
|
|
|
|
/// <summary>Abandons the generate form without making anything.</summary>
|
|
[RelayCommand]
|
|
private void CancelGenerateKey()
|
|
{
|
|
IsGeneratingKey = false;
|
|
GenerateComment = string.Empty;
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Makes a new key pair and drops it into the key editor, unsaved.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>It does not save.</b> What comes back lands in the editor and waits for SAVE, so the whole
|
|
/// storage path — validation, encoding, the outbox, the push — is the one that already exists and this
|
|
/// command has no second version of it. It also means a generated key can be renamed or annotated
|
|
/// before it is written, and abandoned by pressing CANCEL.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Off the UI thread.</b> RSA at 4096 bits is seconds of solid CPU, which on this thread is a frozen
|
|
/// window at the moment somebody is watching it — the same reason key derivation runs on a worker. The
|
|
/// generator does not know which algorithm is cheap, and neither should this.
|
|
/// </para>
|
|
/// <para>
|
|
/// The private key exists in memory from here until the editor is cleared, as a pasted one does. See
|
|
/// <c>SshKeySecret</c> for why a .NET string is the honest choice for that and what it does not buy.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task GenerateKeyAsync(CancellationToken cancellationToken)
|
|
{
|
|
var algorithm = GenerateAlgorithm;
|
|
var comment = string.IsNullOrWhiteSpace(GenerateComment)
|
|
? $"{Environment.UserName}@{Environment.MachineName}"
|
|
: GenerateComment.Trim();
|
|
|
|
var kind = algorithm is SshKeyAlgorithm.Rsa4096 ? "RSA 4096-bit" : "Ed25519";
|
|
|
|
await RunAsync(
|
|
$"Generating a {kind} key…",
|
|
async () =>
|
|
{
|
|
var generated = await Task
|
|
.Run(() => SshKeyGenerator.Generate(algorithm, comment), cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
IsGeneratingKey = false;
|
|
editingKeyId = null;
|
|
|
|
// Filed where a pasted key would be, and set here rather than left over from whatever was
|
|
// edited last: this path opens the same editor without going through NewKey, so without
|
|
// this a key generated after editing a team's key would be saved into that team's vault.
|
|
editingKeyVaultId = TargetVaultId;
|
|
|
|
ClearKeyEditor();
|
|
|
|
KeyEditorLabel = comment;
|
|
KeyEditorPrivateKey = generated.PrivateKeyArmour;
|
|
KeyEditorPublicKey = generated.PublicKeyLine;
|
|
KeyEditorNotes = $"Generated by DodoSSH. {generated.Fingerprint}";
|
|
|
|
IsEditingKey = true;
|
|
|
|
Status = $"Generated {generated.Fingerprint}. Nothing is stored until you press SAVE.";
|
|
}).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts the selected key's public half on the clipboard.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The public half only, and there is deliberately no command for the other one. Installing a key means
|
|
/// pasting this line into a host's <c>authorized_keys</c>; a private key on a clipboard is a private key
|
|
/// in every application on the machine and in whatever syncs it between them.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task CopyPublicKeyAsync()
|
|
{
|
|
if (SelectedKey is not { } row)
|
|
{
|
|
Status = "Choose a key first.";
|
|
return;
|
|
}
|
|
|
|
if (row.Key.PublicKey is not { Length: > 0 } line)
|
|
{
|
|
// Not derivable here: SshKeySecret stores whatever armour it was given and declines to parse
|
|
// it, so a key imported without its .pub has no public half to offer. Saying so beats copying
|
|
// an empty string.
|
|
Status = $"'{row.Label}' has no public half stored. Paste it into the key's editor to keep it.";
|
|
return;
|
|
}
|
|
|
|
if (copyToClipboard is null)
|
|
{
|
|
Status = "This machine has no clipboard.";
|
|
return;
|
|
}
|
|
|
|
await copyToClipboard(line).ConfigureAwait(true);
|
|
|
|
Status = $"Copied the public key for '{row.Label}'. Add it to the host's ~/.ssh/authorized_keys.";
|
|
}
|
|
|
|
/// <summary>Abandons the key editor, clearing the material out of it.</summary>
|
|
[RelayCommand]
|
|
private void CancelKeyEdit()
|
|
{
|
|
IsEditingKey = false;
|
|
editingKeyId = null;
|
|
ClearKeyEditor();
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <summary>Stores the key editor's contents, encrypted, and queues it for the server.</summary>
|
|
[RelayCommand]
|
|
private async Task SaveKeyAsync(CancellationToken cancellationToken)
|
|
{
|
|
var key = BuildKey();
|
|
|
|
if (!key.TryValidate(out var reason))
|
|
{
|
|
Status = reason;
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
if (editingKeyId is { } entityId)
|
|
{
|
|
await session.SshKeys
|
|
.UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
editingKeyId = await session.SshKeys
|
|
.CreateAsync(editingKeyVaultId, key, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
IsEditingKey = false;
|
|
ClearKeyEditor();
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == editingKeyId);
|
|
editingKeyId = null;
|
|
|
|
Status = connection() is null
|
|
? $"Saved '{key.Label}'. It will sync when you are online."
|
|
: $"Saved '{key.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Asks whether the selected key should go.</summary>
|
|
/// <remarks>
|
|
/// The private key is the thing this vault holds that is least likely to exist anywhere else, which is
|
|
/// why the question says so. What it does not say is that the key is gone from the machines it was
|
|
/// installed on: deleting it here removes this vault's copy, and the <c>authorized_keys</c> file on a
|
|
/// server is not something this application has ever written to.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void DeleteKey()
|
|
{
|
|
if (SelectedKey is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
DeletionTarget.Key,
|
|
row.EntityId,
|
|
$"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(ResolvedBindingKind.SshKey, row.EntityId));
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for the key that was agreed to.</summary>
|
|
private async Task DeleteKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (Keys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
Status = "That key is no longer here, so nothing was deleted.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Deleting…",
|
|
async () =>
|
|
{
|
|
await session.SshKeys
|
|
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
Status = $"Deleted '{row.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Starts a new credential.</summary>
|
|
[RelayCommand]
|
|
private void NewCredential()
|
|
{
|
|
if (AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Section = VaultSection.Credentials;
|
|
editingCredentialId = null;
|
|
editingCredentialVaultId = TargetVaultId;
|
|
ClearCredentialEditor();
|
|
IsEditingCredential = true;
|
|
Status = "Adding a credential.";
|
|
}
|
|
|
|
/// <summary>Opens the selected credential for editing.</summary>
|
|
/// <remarks>
|
|
/// The password is loaded into the editor, as the private key is and for the same reason: the codec has no
|
|
/// notion of a partial update, so saving re-encodes every field.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void EditSelectedCredential()
|
|
{
|
|
if (SelectedCredential is not { } row || AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
Status = "This credential was written by a newer version of DodoSSH. Update before editing it.";
|
|
return;
|
|
}
|
|
|
|
Section = VaultSection.Credentials;
|
|
editingCredentialId = row.EntityId;
|
|
editingCredentialVaultId = row.VaultId;
|
|
CredentialEditorLabel = row.Credential.Label;
|
|
CredentialEditorUsername = row.Credential.Username ?? string.Empty;
|
|
CredentialEditorPassword = row.Credential.Password;
|
|
CredentialEditorNotes = row.Credential.Notes ?? string.Empty;
|
|
IsEditingCredential = true;
|
|
Status = $"Editing {row.Label}.";
|
|
}
|
|
|
|
/// <summary>Starts a new bucket.</summary>
|
|
/// <remarks>
|
|
/// Path-style addressing starts off, which is the AWS default — and <see cref="EditObjectStore"/> loads
|
|
/// whatever was stored. Somebody adding a self-hosted bucket turns it on, and the field says why.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void NewObjectStore()
|
|
{
|
|
if (AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
Section = VaultSection.Buckets;
|
|
editingObjectStoreId = null;
|
|
ClearObjectStoreEditor();
|
|
IsEditingObjectStore = true;
|
|
Status = "Adding a bucket.";
|
|
}
|
|
|
|
/// <summary>Opens the selected bucket for editing.</summary>
|
|
[RelayCommand]
|
|
private void EditObjectStore()
|
|
{
|
|
if (SelectedObjectStore is not { } row || AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
Status = "This bucket was written by a newer version of DodoSSH. Update before editing it.";
|
|
return;
|
|
}
|
|
|
|
Section = VaultSection.Buckets;
|
|
editingObjectStoreId = row.EntityId;
|
|
BucketEditorLabel = row.Store.Label;
|
|
BucketEditorBucket = row.Store.Bucket;
|
|
BucketEditorAccessKeyId = row.Store.AccessKeyId;
|
|
BucketEditorSecretAccessKey = row.Store.SecretAccessKey;
|
|
BucketEditorRegion = row.Store.Region ?? string.Empty;
|
|
BucketEditorEndpoint = row.Store.Endpoint ?? string.Empty;
|
|
BucketEditorUsePathStyle = row.Store.UsePathStyle;
|
|
BucketEditorNotes = row.Store.Notes ?? string.Empty;
|
|
IsEditingObjectStore = true;
|
|
Status = $"Editing {row.Label}.";
|
|
}
|
|
|
|
/// <summary>Abandons the bucket editor, clearing the secret access key out of it.</summary>
|
|
[RelayCommand]
|
|
private void CancelObjectStoreEdit()
|
|
{
|
|
IsEditingObjectStore = false;
|
|
editingObjectStoreId = null;
|
|
ClearObjectStoreEditor();
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <summary>Stores the bucket editor's contents, encrypted, and queues it for the server.</summary>
|
|
[RelayCommand]
|
|
private async Task SaveObjectStoreAsync(CancellationToken cancellationToken)
|
|
{
|
|
var store = BuildObjectStore();
|
|
|
|
if (!store.TryValidate(out var reason))
|
|
{
|
|
Status = reason;
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
if (editingObjectStoreId is { } entityId)
|
|
{
|
|
await session.ObjectStores
|
|
.UpdateAsync(session.ActiveVaultId, entityId, store, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
editingObjectStoreId = await session.ObjectStores
|
|
.CreateAsync(session.ActiveVaultId, store, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
IsEditingObjectStore = false;
|
|
ClearObjectStoreEditor();
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
SelectedObjectStore = ObjectStores
|
|
.FirstOrDefault(row => row.EntityId == editingObjectStoreId);
|
|
editingObjectStoreId = null;
|
|
|
|
Status = connection() is null
|
|
? $"Saved '{store.Label}'. It will sync when you are online."
|
|
: $"Saved '{store.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Starts a new tag from the keychain screen.</summary>
|
|
/// <remarks>
|
|
/// The second way to make one. The first — the host editor's box — is where it usually happens, because
|
|
/// wanting a tag and tagging a host are the same moment. This is for the times they are not: setting up
|
|
/// a scheme before there is anything to put it on.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void NewTag()
|
|
{
|
|
if (AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
editingTagId = null;
|
|
|
|
// The active vault, not the "new items go to" picker, and this is the one place the difference
|
|
// bites. Keys and credentials are read across every readable vault, so filing one into a team's is
|
|
// safe — it comes back in the list. Tags are read like groups and buckets are: the editable list is
|
|
// the active vault's alone. A tag filed anywhere else would be created, queued for push, reported
|
|
// as added, and then invisible — no row, no count, no entry in any host editor's picker, and
|
|
// nothing on this screen able to rename or delete it, because there is no active-vault switcher to
|
|
// go and find it with. NewObjectStore ignores the picker for exactly this reason.
|
|
editingTagVaultId = session.ActiveVaultId;
|
|
TagEditorLabel = string.Empty;
|
|
IsEditingTag = true;
|
|
Status = "Adding a tag.";
|
|
}
|
|
|
|
/// <summary>Loads the selected tag's name into the box, so saving renames it.</summary>
|
|
[RelayCommand]
|
|
private void EditTag()
|
|
{
|
|
if (SelectedTag is not { } row || AVaultEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
Status = "This tag was written by a newer version of DodoSSH. Update before editing it.";
|
|
return;
|
|
}
|
|
|
|
editingTagId = row.EntityId;
|
|
editingTagVaultId = session.ActiveVaultId;
|
|
TagEditorLabel = row.Label;
|
|
IsEditingTag = true;
|
|
Status = $"Renaming {row.Label}.";
|
|
}
|
|
|
|
/// <summary>Abandons the tag editor.</summary>
|
|
[RelayCommand]
|
|
private void CancelTagEdit()
|
|
{
|
|
IsEditingTag = false;
|
|
editingTagId = null;
|
|
TagEditorLabel = string.Empty;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores the tag in the box, encrypted, and queues it for the server.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <b>A rename is one write, and that is the whole reason this type exists.</b> Every host wearing the
|
|
/// tag names its id, so none of them is touched — which is what makes renaming safe to do casually and
|
|
/// what a string repeated inside twenty payloads could never have offered. See <see cref="TagSecret"/>.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task SaveTagAsync(CancellationToken cancellationToken)
|
|
{
|
|
var tag = new TagSecret { Label = TagEditorLabel.Trim() };
|
|
|
|
if (!tag.TryValidate(out var reason))
|
|
{
|
|
Status = reason;
|
|
return;
|
|
}
|
|
|
|
var renaming = editingTagId;
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
if (renaming is { } entityId)
|
|
{
|
|
await session.Tags
|
|
.UpdateAsync(editingTagVaultId, entityId, tag, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
await session.Tags
|
|
.CreateAsync(editingTagVaultId, tag, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
CancelTagEdit();
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = renaming is null
|
|
? $"Added the tag '{tag.Label}'."
|
|
: $"Renamed to '{tag.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Asks whether the selected tag should go.</summary>
|
|
/// <remarks>
|
|
/// The consequence is worth counting rather than describing. Deleting a tag does not touch the hosts
|
|
/// wearing it — rewriting N payloads inside one delete is what keeping membership on the host exists to
|
|
/// avoid — so what actually happens is that N rows lose a chip, and the number is the difference
|
|
/// between a tidy-up and losing a filter somebody relies on.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void DeleteTag()
|
|
{
|
|
if (SelectedTag is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
DeletionTarget.Tag,
|
|
row.EntityId,
|
|
$"Delete the tag '{row.Label}'?",
|
|
HowFarADeletionGoes("The tag"),
|
|
row.HostCount == 0
|
|
? string.Empty
|
|
: row.HostCount == 1
|
|
? "1 host wears it and will simply stop showing the chip. Nothing else about it changes."
|
|
: $"{row.HostCount} hosts wear it and will simply stop showing the chip. Nothing else "
|
|
+ "about them changes.");
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for the tag that was agreed to.</summary>
|
|
private async Task DeleteTagNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (Tags.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
Status = "That tag is no longer here, so nothing was deleted.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Deleting…",
|
|
async () =>
|
|
{
|
|
await session.Tags
|
|
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
if (editingTagId == entityId)
|
|
{
|
|
CancelTagEdit();
|
|
}
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = $"Deleted the tag '{row.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Asks whether the selected bucket should go.</summary>
|
|
/// <remarks>
|
|
/// The bucket itself is untouched, and the question says so. Removing the entry here removes this
|
|
/// keychain's way of reaching it — the objects in it are somebody else's to delete, and a confirmation
|
|
/// that did not distinguish the two would be genuinely frightening.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void DeleteObjectStore()
|
|
{
|
|
if (SelectedObjectStore is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
DeletionTarget.ObjectStore,
|
|
row.EntityId,
|
|
$"Remove the bucket '{row.Label}'?",
|
|
HowFarADeletionGoes("The bucket's address and its keys"),
|
|
"Nothing in the bucket is touched. This removes the way this keychain reaches it, not the "
|
|
+ "objects in it.");
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for the bucket that was agreed to.</summary>
|
|
private async Task DeleteObjectStoreNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (ObjectStores.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
Status = "That bucket is no longer here, so nothing was removed.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Removing…",
|
|
async () =>
|
|
{
|
|
await session.ObjectStores
|
|
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
Status = $"Removed '{row.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Empties the bucket editor, including the secret access key.</summary>
|
|
private void ClearObjectStoreEditor()
|
|
{
|
|
BucketEditorLabel = string.Empty;
|
|
BucketEditorBucket = string.Empty;
|
|
BucketEditorAccessKeyId = string.Empty;
|
|
BucketEditorSecretAccessKey = string.Empty;
|
|
BucketEditorRegion = string.Empty;
|
|
BucketEditorEndpoint = string.Empty;
|
|
BucketEditorUsePathStyle = false;
|
|
BucketEditorNotes = string.Empty;
|
|
}
|
|
|
|
private ObjectStoreSecret BuildObjectStore() =>
|
|
new()
|
|
{
|
|
Label = BucketEditorLabel.Trim(),
|
|
Bucket = BucketEditorBucket.Trim(),
|
|
|
|
// Trimmed, both of them. A pasted access key with a trailing newline signs every request wrongly
|
|
// and the service answers "SignatureDoesNotMatch", which names neither the field nor the paste.
|
|
AccessKeyId = BucketEditorAccessKeyId.Trim(),
|
|
SecretAccessKey = BucketEditorSecretAccessKey.Trim(),
|
|
|
|
// Blank is a real answer for both — no region, or no custom endpoint — and is stored as null so
|
|
// that ObjectStoreSecret can tell "not set" from "set to nothing".
|
|
Region = string.IsNullOrWhiteSpace(BucketEditorRegion) ? null : BucketEditorRegion.Trim(),
|
|
Endpoint = string.IsNullOrWhiteSpace(BucketEditorEndpoint)
|
|
? null
|
|
: BucketEditorEndpoint.Trim().TrimEnd('/'),
|
|
UsePathStyle = BucketEditorUsePathStyle,
|
|
Notes = string.IsNullOrWhiteSpace(BucketEditorNotes) ? null : BucketEditorNotes,
|
|
};
|
|
|
|
/// <summary>Abandons the credential editor, clearing the password out of it.</summary>
|
|
[RelayCommand]
|
|
private void CancelCredentialEdit()
|
|
{
|
|
IsEditingCredential = false;
|
|
editingCredentialId = null;
|
|
ClearCredentialEditor();
|
|
Status = string.Empty;
|
|
}
|
|
|
|
/// <summary>Stores the credential editor's contents, encrypted, and queues it for the server.</summary>
|
|
[RelayCommand]
|
|
private async Task SaveCredentialAsync(CancellationToken cancellationToken)
|
|
{
|
|
var credential = BuildCredential();
|
|
|
|
if (!credential.TryValidate(out var reason))
|
|
{
|
|
Status = reason;
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Saving…",
|
|
async () =>
|
|
{
|
|
if (editingCredentialId is { } entityId)
|
|
{
|
|
await session.Credentials
|
|
.UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
else
|
|
{
|
|
editingCredentialId = await session.Credentials
|
|
.CreateAsync(editingCredentialVaultId, credential, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
|
|
IsEditingCredential = false;
|
|
ClearCredentialEditor();
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
SelectedCredential = Credentials
|
|
.FirstOrDefault(row => row.EntityId == editingCredentialId);
|
|
editingCredentialId = null;
|
|
|
|
Status = connection() is null
|
|
? $"Saved '{credential.Label}'. It will sync when you are online."
|
|
: $"Saved '{credential.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Asks whether the selected credential should go.</summary>
|
|
[RelayCommand]
|
|
private void DeleteCredential()
|
|
{
|
|
if (SelectedCredential is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = new DeletionRequest(
|
|
DeletionTarget.Credential,
|
|
row.EntityId,
|
|
$"Delete the password '{row.Label}'?",
|
|
HowFarADeletionGoes("The password and the account saved with it"),
|
|
HostsBoundTo(ResolvedBindingKind.Credential, row.EntityId));
|
|
}
|
|
|
|
/// <summary>Queues a tombstone for the credential that was agreed to.</summary>
|
|
private async Task DeleteCredentialNowAsync(Guid entityId, CancellationToken cancellationToken)
|
|
{
|
|
if (Credentials.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
Status = "That password is no longer here, so nothing was deleted.";
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
"Deleting…",
|
|
async () =>
|
|
{
|
|
await session.Credentials
|
|
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
Status = $"Deleted '{row.Label}'.";
|
|
}).ConfigureAwait(true);
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Carries out the deletion that was asked about.</summary>
|
|
/// <remarks>
|
|
/// Disarmed before the work rather than after it, so that the card goes the moment it is answered and a
|
|
/// second press during a slow round trip has nothing left to agree to.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (PendingDeletion is not { } request)
|
|
{
|
|
return;
|
|
}
|
|
|
|
PendingDeletion = null;
|
|
|
|
switch (request.Target)
|
|
{
|
|
case DeletionTarget.Host:
|
|
await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
|
break;
|
|
|
|
case DeletionTarget.Key:
|
|
await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
|
break;
|
|
|
|
case DeletionTarget.Credential:
|
|
await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
|
break;
|
|
|
|
case DeletionTarget.Group:
|
|
await DeleteGroupNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
|
break;
|
|
|
|
case DeletionTarget.ObjectStore:
|
|
await DeleteObjectStoreNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
|
break;
|
|
|
|
case DeletionTarget.Tag:
|
|
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>Thinks better of it.</summary>
|
|
[RelayCommand]
|
|
private void CancelDelete() => PendingDeletion = null;
|
|
|
|
/// <summary>
|
|
/// Where a deleted item goes, and how far.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The offline branch is the same distinction saving makes, and it matters more here: a tombstone that
|
|
/// has not been pushed is a deletion the other machines have not heard about, and somebody deleting a
|
|
/// credential because it leaked should be told which of those two they have just done.
|
|
/// </remarks>
|
|
private string HowFarADeletionGoes(string what) => connection() is null
|
|
? $"{what} goes from this machine now, and from your other machines once this one is online again. "
|
|
+ "There is no undo."
|
|
: $"{what} goes from this machine now, and from your other machines at the next synchronisation. "
|
|
+ "There is no undo.";
|
|
|
|
/// <summary>
|
|
/// 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(ResolvedBindingKind kind, Guid entityId)
|
|
{
|
|
var bound = Hosts
|
|
.Where(row => row.Resolved.Binding is { } binding
|
|
&& binding.Kind == kind
|
|
&& binding.EntityId == entityId)
|
|
.Select(row => row.Label)
|
|
.ToArray();
|
|
|
|
if (bound.Length == 0)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
// Three names and a count past that, because this is read in a 244-pixel column and a vault with
|
|
// twenty hosts on one key would otherwise put a paragraph of names where a warning should be.
|
|
var named = bound.Length <= 3
|
|
? string.Join(", ", bound)
|
|
: $"{string.Join(", ", bound.Take(3))} and {bound.Length - 3} more";
|
|
|
|
return bound.Length == 1
|
|
? $"{named} authenticates with it, and will refuse to connect rather than fall back to a typed "
|
|
+ "password."
|
|
: $"{bound.Length} hosts authenticate with it — {named} — and will refuse to connect rather than "
|
|
+ "fall back to a typed password.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws trust from the selected pin's endpoint.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Goes through the same <c>ForgetAsync</c> as the host editor's button, which withdraws every pin for
|
|
/// the endpoint rather than the one row that was selected. That is deliberate and not a shortcut: trust
|
|
/// is about an address, a second pin for the same address under another algorithm would go on being
|
|
/// offered at the next handshake, and a user who has decided to stop trusting a machine has not decided
|
|
/// to stop trusting one of its keys. The status line says how many went.
|
|
/// </para>
|
|
/// <para>
|
|
/// No confirmation. Withdrawing trust costs one fingerprint check on the next connection, and it is the
|
|
/// safe direction to be wrong in — the dangerous button is the one that adds trust, and that one is the
|
|
/// prompt at connect time.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ForgetPinAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (SelectedKnownHost is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
await RunAsync(
|
|
$"Forgetting the pinned host key for {row.Host}…",
|
|
async () =>
|
|
{
|
|
var forgotten = await knownHosts
|
|
.ForgetAsync(row.Host, row.Port, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
// A mismatch the user was staring at is about a pin that may have just gone.
|
|
HostKeyMismatch = null;
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = forgotten == 1
|
|
? $"Forgot the pinned key for {row.Host}:{row.Port}."
|
|
: $"Forgot {forgotten} pinned key(s) for {row.Host}:{row.Port}.";
|
|
}).ConfigureAwait(true);
|
|
|
|
// Pushed straight away, as trusting is: the other machines are the ones still refusing to connect to
|
|
// a server that has been rebuilt.
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Opens a terminal on the selected host.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Deliberately not inside <see cref="RunAsync"/>, unlike every other command here.</b> That gate is
|
|
/// what makes the vault do one thing at a time, and connecting is the one operation that must not hold
|
|
/// it: a handshake is a network round trip against a machine that may be asleep, and holding the gate
|
|
/// for it means a window in which nothing else can be saved, edited or even connected to. The strip
|
|
/// carries the feedback instead — <see cref="ConnectionStarting"/> puts a tab there before anything is
|
|
/// dialled — so the wait is visible without being in the way. Everything <c>RunAsync</c> would have done
|
|
/// for the failures is done by <see cref="OpenSessionAsync"/>, which reports every one of them.
|
|
/// </para>
|
|
/// <para>
|
|
/// Several connections can therefore be in flight at once, which is why an attempt has an id and why
|
|
/// this command allows concurrent executions. That is a feature rather than a tolerated race: opening
|
|
/// three machines is one of the ordinary things to do with a tabbed client, and it used to mean waiting
|
|
/// for each in turn. Without the flag the generated command refuses a second call outright while the
|
|
/// first is running — silently, as a no-op — which would be the old one-at-a-time behaviour with none of
|
|
/// the explanation.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It takes no cancellation token, and that is what makes the flag above mean anything.</b> A
|
|
/// <c>[RelayCommand]</c> over a method that takes one generates a command which cancels the previous
|
|
/// execution's token every time it is invoked — so a second connection would quietly abandon the first,
|
|
/// which is the exact opposite of what opening two machines at once is supposed to do. Measured: the
|
|
/// first tab disappeared with "Cancelled." the instant the second was asked for. What is given up by not
|
|
/// having one is a way to abort a handshake from here; closing the tab is that, and the session it
|
|
/// abandons is adopted rather than lost. See <c>MainWindowViewModel.CloseTabAsync</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
|
private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None);
|
|
|
|
/// <inheritdoc cref="ConnectAsync" />
|
|
/// <param name="cancellationToken">
|
|
/// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own,
|
|
/// which is a different command's and so is not cancelled by anyone else connecting.
|
|
/// </param>
|
|
private async Task ConnectToSelectedHostAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (SelectedHost is not { } row)
|
|
{
|
|
Status = "Choose a host first.";
|
|
return;
|
|
}
|
|
|
|
// 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, row.Resolved, ConnectPassword, out var authentication, out var refusal))
|
|
{
|
|
Status = refusal;
|
|
return;
|
|
}
|
|
|
|
PendingHostKey = null;
|
|
HostKeyMismatch = null;
|
|
|
|
// Before the first await, so the tab is in the strip in the same turn the user asked for it. The
|
|
// address is the one that will actually be dialled — a bound credential can supply the username —
|
|
// rather than the host's own fields, so the tab does not rename itself on connecting.
|
|
var attempt = new ConnectionAttemptEventArgs(
|
|
Guid.CreateVersion7(),
|
|
row.Label,
|
|
Dialled(row, authentication));
|
|
|
|
ConnectionStarting?.Invoke(this, attempt);
|
|
Status = $"Connecting to {row.Label}…";
|
|
|
|
await OpenSessionAsync(attempt, row, authentication, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pins the offered host key and retries.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The pin goes into the vault, so this writes to the local cache and queues a change for every other
|
|
/// machine — which is why the write is guarded and the connection is only retried once it has landed.
|
|
/// It used to be a dictionary insert that could not fail; reporting a failed write as a failed
|
|
/// connection would send the user looking at the host.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (PendingHostKey is not { } presentation)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Status = "Cancelled.";
|
|
return;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
|
|
return;
|
|
}
|
|
|
|
PendingHostKey = null;
|
|
|
|
await ConnectToSelectedHostAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
// After connecting, not before. A pin is worth pushing straight away — the same host on another
|
|
// machine should not ask again — but not at the cost of delaying the connection the user asked for.
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
|
|
[RelayCommand]
|
|
private void RejectHostKey()
|
|
{
|
|
PendingHostKey = null;
|
|
Status = "The host key was not trusted, so nothing was connected.";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Withdraws trust from every key pinned for the host being edited.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The counterpart to trust that now outlives the process, and the reason it exists at all: a mismatch is
|
|
/// a hard refusal with no way past it, so a server that is legitimately rebuilt would be unreachable for
|
|
/// ever without this. It is deliberately <em>here</em> — in the host's editor, reached by choosing to edit
|
|
/// a host — and not on the refusal itself. A "forget this key" button next to the warning is the same
|
|
/// button as "continue anyway" with two clicks instead of one.
|
|
/// </para>
|
|
/// <para>
|
|
/// Applies to the host's <em>saved</em> address rather than whatever the editor's boxes currently hold.
|
|
/// The pin belongs to the endpoint that was actually dialled, and someone halfway through retyping a
|
|
/// hostname has not moved it yet.
|
|
/// </para>
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ForgetHostKeyAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (editingEntityId is not { } entityId
|
|
|| Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var address = row.Host.Hostname;
|
|
|
|
// The dialled port, because that is what the pin is filed under. A host inheriting 2222 from its
|
|
// group was pinned at 2222, and forgetting under 22 would leave the pin that caused the mismatch
|
|
// exactly where it was.
|
|
var port = Resolve(row.Host).Port.Value;
|
|
|
|
await RunAsync(
|
|
$"Forgetting the pinned host key for {address}…",
|
|
async () =>
|
|
{
|
|
var forgotten = await knownHosts
|
|
.ForgetAsync(address, port, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
// The refusal that sent the user here is about a pin that no longer exists.
|
|
HostKeyMismatch = null;
|
|
|
|
PendingChanges = await session
|
|
.PendingChangeCountAsync(cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
if (forgotten == 0)
|
|
{
|
|
Status = $"Nothing was pinned for {address}:{port}.";
|
|
return;
|
|
}
|
|
|
|
Status = $"Forgot the pinned host key for {address}:{port}. The next connection will "
|
|
+ "ask you to check its fingerprint again.";
|
|
}).ConfigureAwait(true);
|
|
|
|
// Pushed straight away, as a save or a deletion is: a withdrawal that stayed on this machine would
|
|
// leave the other ones refusing to connect to a server that has been rebuilt.
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks every shown conflict as seen.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who
|
|
/// dismisses this and realises a minute later that they wanted the other value should still be able to
|
|
/// get it.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken)
|
|
{
|
|
foreach (var conflict in Conflicts.ToArray())
|
|
{
|
|
await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
if (disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
disposed = true;
|
|
|
|
// The loop is stopped and awaited before the session goes, not merely signalled. A pass in flight
|
|
// holds the vault keys and the cache; letting it run on into a disposed session is how locking
|
|
// turns into an ObjectDisposedException on a background thread that nobody sees.
|
|
if (autoSync is not null)
|
|
{
|
|
await autoSync.CancelAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
if (autoSyncLoop is not null)
|
|
{
|
|
await autoSyncLoop.ConfigureAwait(false);
|
|
}
|
|
|
|
autoSync?.Dispose();
|
|
syncGate.Dispose();
|
|
|
|
await session.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>Connects, and turns every way of not connecting into something a tab can carry.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
|
|
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
|
|
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
|
|
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The timeout is translated rather than reported.</b> <see cref="TimeoutException"/> says only "The
|
|
/// operation has timed out", and the one thing worth saying is where to look: a runtime this application
|
|
/// does not install.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>An unknown host key is not a failure and is deliberately not logged.</b> Nothing was refused and
|
|
/// nothing broke — the connection is paused on a question, and it becomes a session the moment the user
|
|
/// answers it. An entry here would record a failure that did not happen, once per new host. A changed
|
|
/// key <em>is</em> logged, and it is the entry the connection log most exists for: it is refused outright
|
|
/// with no way past it, so the only trace it would otherwise leave is a status line the user dismisses.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Everything else is caught by shape rather than by type.</b> This project's SSH layer defines only
|
|
/// the two host-key exceptions; an unreachable host, a rejected password and a key the remote will not
|
|
/// take all arrive from SSH.NET, which the client deliberately does not reference. Each is recorded
|
|
/// before it is reported — the log is an observer here and must never become the thing that swallows an
|
|
/// error. Cancellation is excluded from that, because a user who gave up did not fail to connect.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task OpenSessionAsync(
|
|
ConnectionAttemptEventArgs attempt,
|
|
HostRowViewModel row,
|
|
HostAuthentication authentication,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await ConnectAndAnnounceAsync(attempt, row, authentication, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
Abandon(
|
|
attempt,
|
|
"The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
|
+ "runtime is probably missing or blocked; install it and try again.");
|
|
}
|
|
catch (SshHostKeyUnknownException exception)
|
|
{
|
|
PendingHostKey = exception.Presentation;
|
|
Answer(attempt, "This host has not been seen before.");
|
|
}
|
|
catch (SshHostKeyMismatchException exception)
|
|
{
|
|
RecordFailure(row, authentication, ConnectionOutcome.Refused);
|
|
|
|
HostKeyMismatch = exception.Message;
|
|
Answer(attempt, "The host key has changed. The connection was refused.");
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Answer(attempt, "Cancelled.");
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
RecordFailure(row, authentication, ConnectionOutcome.Failed);
|
|
Abandon(attempt, exception.Message);
|
|
}
|
|
}
|
|
|
|
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
|
|
/// <remarks>
|
|
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
|
|
/// looking, and the tab is where somebody who navigated away will find it — which is now the ordinary
|
|
/// case, because connecting does not hold the window still any more.
|
|
/// </remarks>
|
|
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
|
{
|
|
Status = reason;
|
|
|
|
ConnectionFailed?.Invoke(
|
|
this,
|
|
new ConnectionFailedEventArgs(attempt.AttemptId, reason, isAwaitingAnAnswer: false));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same, for an attempt that stopped on something the user has to answer rather than on a failure.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The difference is what the shell does with the tab: a refusal keeps it, and a question takes it away
|
|
/// so the window can show the question instead. See <see cref="ConnectionFailedEventArgs"/>. Cancelling
|
|
/// counts as a question in the sense that matters here — the tab is going either way, and nothing about
|
|
/// it is worth keeping on screen.
|
|
/// </remarks>
|
|
private void Answer(ConnectionAttemptEventArgs attempt, string status)
|
|
{
|
|
Status = status;
|
|
|
|
ConnectionFailed?.Invoke(
|
|
this,
|
|
new ConnectionFailedEventArgs(attempt.AttemptId, status, isAwaitingAnAnswer: true));
|
|
}
|
|
|
|
/// <summary>Opens the session and tells the shell about it. Every failure is a throw.</summary>
|
|
/// <remarks>
|
|
/// Split from the handlers around it for length, and the split falls where it should: this is the whole
|
|
/// happy path, and everything above it is one <c>catch</c> per way of not having one.
|
|
/// </remarks>
|
|
private async Task ConnectAndAnnounceAsync(
|
|
ConnectionAttemptEventArgs attempt,
|
|
HostRowViewModel row,
|
|
HostAuthentication authentication,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
var request = new SshConnectionRequest(
|
|
row.Host.Hostname,
|
|
Resolve(row.Host).Port.Value,
|
|
authentication.Username,
|
|
authentication.Credential);
|
|
|
|
var sessionId = await workspace
|
|
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
// The workspace has already opened a ticket for this session, with the address and the moment it
|
|
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
|
// notion of one — so the name is added here rather than the ticket being replaced, which would move
|
|
// the start time to now.
|
|
connectionLog?.Identify(sessionId, row.Label, row.EntityId);
|
|
|
|
Status = $"Connected to {row.Label}.";
|
|
|
|
// Only now, and only on success. The page's own term.focus() focuses the textarea inside the
|
|
// document, which does nothing while the window's keyboard focus is still on the Connect button — so
|
|
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
|
SessionOpened?.Invoke(
|
|
this,
|
|
new TerminalSessionEventArgs(
|
|
attempt.AttemptId,
|
|
sessionId,
|
|
row.Label,
|
|
Dialled(row, authentication)));
|
|
|
|
// 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.
|
|
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns the password that just worked into a keychain credential bound to this host.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Only after a handshake the remote accepted.</b> Storing a password the moment it is typed would
|
|
/// bind whatever was in the box — including the typo that is about to be refused — and the host would
|
|
/// then stop asking, leaving a machine that cannot be connected to until somebody works out that the
|
|
/// keychain is where the wrong password now lives.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
|
|
/// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type
|
|
/// exists — bound to the other nineteen machines that share the account. See <see cref="HostSecret"/>
|
|
/// on why the binding is an id and not a copy.
|
|
/// </para>
|
|
/// <para>
|
|
/// The credential carries no username of its own, so it keeps taking the host's — which is what the
|
|
/// connection that just succeeded did. Copying the resolved username into it would pin whatever the
|
|
/// group happened to say at this moment, and quietly stop following the group afterwards.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every failure is reported and swallowed. The caller's <c>catch</c> blocks describe a connection that
|
|
/// did not happen, and this one did: a vault write that fails here must not tell the user their terminal
|
|
/// was abandoned, and a cancellation must not report it as cancelled.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task RememberTypedPasswordAsync(
|
|
HostRowViewModel row,
|
|
HostAuthentication authentication,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
// The password as dialled, not as the box currently reads: the two can differ by now, because a
|
|
// handshake takes time and the box stays typeable throughout it.
|
|
if (!RemembersConnectPassword
|
|
|| row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword
|
|
|| authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (row.IsReadOnly)
|
|
{
|
|
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
|
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
|
return;
|
|
}
|
|
|
|
var credential = new CredentialSecret { Label = row.Label, Password = typed.Password };
|
|
|
|
try
|
|
{
|
|
var credentialId = await session.Credentials
|
|
.CreateAsync(row.VaultId, credential, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
// Into the same vault as the host, deliberately: a credential in the personal vault bound to a
|
|
// team's host is a binding every other member can see and none of them can resolve.
|
|
await session.Hosts
|
|
.UpdateAsync(
|
|
row.VaultId,
|
|
row.EntityId,
|
|
row.Host with { CredentialId = credentialId, AsksForPassword = null },
|
|
cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
|
return;
|
|
}
|
|
|
|
// Cleared together. The box is about to disappear — the host answers "credential" now — and a tick
|
|
// left behind would apply to the next host somebody selects.
|
|
RemembersConnectPassword = false;
|
|
ConnectPassword = string.Empty;
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
|
+ "will not be asked for again.";
|
|
|
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
}
|
|
|
|
/// <summary>The address as actually dialled.</summary>
|
|
/// <remarks>
|
|
/// 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.Resolved.Port.Value}");
|
|
|
|
/// <summary>
|
|
/// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Rate-limited, because pruning writes.</b> Log entries are synced items, so removing one is a real
|
|
/// tombstone that goes to the server — and a prune on every sync tick would be a machine that writes to
|
|
/// a server once a minute for ever. It rides the existing loop rather than a timer of its own, so a
|
|
/// laptop that is asleep prunes nothing and one that is awake prunes when it was going to talk to the
|
|
/// server anyway.
|
|
/// </para>
|
|
/// <para>
|
|
/// The first pass after a vault opens always runs, which is what makes a machine that has been off for a
|
|
/// month tidy up as soon as it comes back.
|
|
/// </para>
|
|
/// <para>
|
|
/// Failures are swallowed. Retention is housekeeping; a vault that could not prune is not a vault
|
|
/// somebody needs to be told about mid-sync.
|
|
/// </para>
|
|
/// </remarks>
|
|
private async Task PruneLogsIfDueAsync(CancellationToken cancellationToken)
|
|
{
|
|
var now = TimeProvider.System.GetUtcNow();
|
|
|
|
if (lastPruned is { } previous && now - previous < PruneInterval)
|
|
{
|
|
return;
|
|
}
|
|
|
|
lastPruned = now;
|
|
|
|
try
|
|
{
|
|
await LogPruner.PruneAsync(session, LogRetention.Default, now, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// Locking, or closing.
|
|
}
|
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
|
{
|
|
// Housekeeping. Nothing the user did failed, and there is nothing for them to do about it.
|
|
}
|
|
}
|
|
|
|
/// <summary>Records a connection that never became a session.</summary>
|
|
/// <remarks>
|
|
/// Start and end are the same instant, which is what a connection that never opened actually looks like:
|
|
/// the duration is zero and the outcome carries the meaning.
|
|
/// </remarks>
|
|
private void RecordFailure(
|
|
HostRowViewModel row,
|
|
HostAuthentication authentication,
|
|
ConnectionOutcome outcome)
|
|
{
|
|
var at = TimeProvider.System.GetUtcNow();
|
|
|
|
connectionLog?.Record(
|
|
Dialled(row, authentication),
|
|
row.Label,
|
|
row.EntityId,
|
|
ConnectionKind.Terminal,
|
|
at,
|
|
at,
|
|
outcome);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Everything the SSH stack needs to authenticate as somebody on a host.
|
|
/// </summary>
|
|
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
|
|
/// <param name="Credential">What proves it.</param>
|
|
/// <remarks>
|
|
/// The two travel together because a credential can change both. Returning only the secret and reading the
|
|
/// username off the host separately is what the connect path used to do, and it would have sent a stored
|
|
/// credential's password under the host's username — which is the one combination that is wrong in a way
|
|
/// the server reports as "authentication failed".
|
|
/// </remarks>
|
|
private sealed record HostAuthentication(string Username, SshCredential Credential);
|
|
|
|
/// <summary>
|
|
/// Works out how a host authenticates, or says why it cannot.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The key material is handed over as UTF-8 bytes, which is what <c>PrivateKeyFile</c> reads from a
|
|
/// <c>MemoryStream</c> — so the key reaches SSH.NET without ever becoming a file on disk. The
|
|
/// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point
|
|
/// of a vault, and <c>SshKeySecret</c> says why. It is passed straight through with no empty-to-null
|
|
/// check, because <c>SshKeySecret.Passphrase</c> cannot hold an empty string.
|
|
/// </para>
|
|
/// <para>
|
|
/// False is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means the
|
|
/// key or credential was deleted on another machine — plausible, and no reason to start sending a typed
|
|
/// password to a host somebody deliberately set up not to accept one.
|
|
/// </para>
|
|
/// <para>
|
|
/// The credential branch comes first because the two bindings are mutually exclusive and a host carrying
|
|
/// both is already invalid; reading the credential first means a host that somehow acquired both is
|
|
/// answered by the more specific of the two rather than by whichever the code happened to check.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <summary>
|
|
/// Works out how to reach a host, or says why it cannot.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same resolution the Connect button performs, exposed because file transfer opens its own
|
|
/// connection — see <c>ISftpSession</c> — and a second copy of "which key, which password, whose
|
|
/// username" would be a second place for a dangling binding to be silently turned back into a typed
|
|
/// password. The typed password is a parameter rather than <see cref="ConnectPassword"/> because the
|
|
/// transfers screen has its own box: they are different screens, and a password typed on one is not a
|
|
/// password offered on the other.
|
|
/// </remarks>
|
|
internal bool TryBuildConnectionRequest(
|
|
HostSecret host,
|
|
string typedPassword,
|
|
[NotNullWhen(true)] out SshConnectionRequest? request,
|
|
[NotNullWhen(false)] out string? reason)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(host);
|
|
|
|
var resolved = Resolve(host);
|
|
|
|
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
|
{
|
|
request = null;
|
|
return false;
|
|
}
|
|
|
|
request = new SshConnectionRequest(
|
|
host.Hostname,
|
|
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)
|
|
{
|
|
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(
|
|
$"{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 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 ?? resolved.Username.Value,
|
|
new SshPasswordCredential(credential.Credential.Password),
|
|
out authentication,
|
|
out reason);
|
|
}
|
|
|
|
if (binding is { Kind: ResolvedBindingKind.SshKey, EntityId: { } keyId })
|
|
{
|
|
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
|
|
{
|
|
return Refuse(
|
|
$"{where} an SSH key that is not in this keychain any more. {repair}",
|
|
out authentication,
|
|
out reason);
|
|
}
|
|
|
|
return Complete(
|
|
resolved.Username.Value,
|
|
new SshPrivateKeyCredential(
|
|
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
|
out authentication,
|
|
out reason);
|
|
}
|
|
|
|
return Complete(
|
|
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,
|
|
SshCredential credential,
|
|
out HostAuthentication? authentication,
|
|
[NotNullWhen(false)] out string? reason)
|
|
{
|
|
if (string.IsNullOrEmpty(username))
|
|
{
|
|
return Refuse(
|
|
"This host has no username. Edit it and add one, or bind it to a credential that carries one.",
|
|
out authentication,
|
|
out reason);
|
|
}
|
|
|
|
authentication = new HostAuthentication(username, credential);
|
|
reason = null;
|
|
return true;
|
|
}
|
|
|
|
private static bool Refuse(
|
|
string reason,
|
|
out HostAuthentication? authentication,
|
|
out string? refusal)
|
|
{
|
|
authentication = null;
|
|
refusal = reason;
|
|
return false;
|
|
}
|
|
|
|
private HostSecret BuildHost() =>
|
|
new()
|
|
{
|
|
Label = EditorLabel.Trim(),
|
|
Hostname = EditorHostname.Trim(),
|
|
|
|
// 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,
|
|
|
|
// The picker's set, and it is the picker's rather than the chips' — see editorTagIds. An id the
|
|
// vault cannot currently offer, because the tag was deleted on another machine between this
|
|
// editor opening and Save, is carried through rather than dropped by an edit that was about
|
|
// something else.
|
|
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
|
|
// control means the two can never both be set: mutual exclusion by construction, rather than
|
|
// HostSecret.TryValidate catching it after the fact.
|
|
SshKeyId = Bound(AuthenticationKind.SshKey),
|
|
CredentialId = Bound(AuthenticationKind.Credential),
|
|
|
|
// Read off the picker for the same reason, including the id of a group that has gone missing:
|
|
// an unrelated edit must not unfile a host as a side effect.
|
|
GroupId = EditorSelectedGroup?.EntityId,
|
|
};
|
|
|
|
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
|
private Guid? Bound(AuthenticationKind kind) =>
|
|
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
|
|
|
/// <summary>
|
|
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
|
/// </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,
|
|
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));
|
|
}
|
|
|
|
foreach (var credential in Credentials)
|
|
{
|
|
EditorAuthenticationChoices.Add(
|
|
AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
|
|
}
|
|
|
|
// At most one of the two is set on a valid host, so at most one placeholder is ever added.
|
|
AddMissing(AuthenticationKind.SshKey, boundKeyId);
|
|
AddMissing(AuthenticationKind.Credential, boundCredentialId);
|
|
|
|
EditorSelectedAuthentication =
|
|
Selected(boundKeyId, boundCredentialId, asksForPassword, grouped);
|
|
}
|
|
|
|
private void AddMissing(AuthenticationKind kind, Guid? boundId)
|
|
{
|
|
if (boundId is { } bound
|
|
&& !EditorAuthenticationChoices.Any(choice => choice.Kind == kind && choice.EntityId == bound))
|
|
{
|
|
EditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound));
|
|
}
|
|
}
|
|
|
|
/// <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,
|
|
bool asksForPassword,
|
|
bool grouped) =>
|
|
(boundKeyId, boundCredentialId) switch
|
|
{
|
|
({ } key, _) => Find(AuthenticationKind.SshKey, key),
|
|
(_, { } credential) => Find(AuthenticationKind.Credential, credential),
|
|
_ when asksForPassword || !grouped => AuthenticationChoice.Typed,
|
|
_ => AuthenticationChoice.Inherited,
|
|
};
|
|
|
|
private AuthenticationChoice Find(AuthenticationKind kind, Guid entityId) =>
|
|
EditorAuthenticationChoices
|
|
.FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId)
|
|
?? AuthenticationChoice.Typed;
|
|
|
|
/// <summary>Fills the group picker, keeping whatever the host is currently filed under selectable.</summary>
|
|
/// <param name="groupId">The group the host names, if any.</param>
|
|
/// <remarks>
|
|
/// A group that is no longer in the vault gets a placeholder, for the reason
|
|
/// <see cref="BuildAuthenticationChoices"/> gives: without one the picker would open on "No group", and
|
|
/// somebody editing the host's port would unfile it by saving. It says the group is gone rather than
|
|
/// naming it, because there is nothing left to read the name off.
|
|
/// </remarks>
|
|
private void BuildGroupChoices(Guid? groupId)
|
|
{
|
|
EditorGroupChoices.Clear();
|
|
EditorGroupChoices.Add(GroupChoice.None);
|
|
|
|
foreach (var group in Groups)
|
|
{
|
|
EditorGroupChoices.Add(new GroupChoice(group.EntityId, group.Label));
|
|
}
|
|
|
|
if (groupId is { } bound && !EditorGroupChoices.Any(choice => choice.EntityId == bound))
|
|
{
|
|
EditorGroupChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
|
|
}
|
|
|
|
EditorSelectedGroup = EditorGroupChoices.FirstOrDefault(choice => choice.EntityId == groupId)
|
|
?? GroupChoice.None;
|
|
}
|
|
|
|
private CredentialSecret BuildCredential() =>
|
|
new()
|
|
{
|
|
Label = CredentialEditorLabel.Trim(),
|
|
|
|
// Not trimmed and not emptied, exactly as a key's passphrase is not: leading or trailing spaces
|
|
// are legitimate in a password, and CredentialSecret refuses an empty one on its own.
|
|
Password = CredentialEditorPassword,
|
|
|
|
// Trimmed, unlike the password. A username with a trailing space is a different account name to
|
|
// sshd, and it is never the one somebody meant.
|
|
Username = CredentialEditorUsername.Trim(),
|
|
Notes = string.IsNullOrWhiteSpace(CredentialEditorNotes) ? null : CredentialEditorNotes,
|
|
};
|
|
|
|
/// <remarks>
|
|
/// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up
|
|
/// would eventually tidy a format it did not fully understand — the same reason
|
|
/// <c>SshKeySecret.PrivateKeyPem</c> stores it verbatim. Everything else is trimmed, because a label
|
|
/// with a trailing space sorts oddly and reads as a different name.
|
|
/// </remarks>
|
|
private SshKeySecret BuildKey() =>
|
|
new()
|
|
{
|
|
Label = KeyEditorLabel.Trim(),
|
|
PrivateKeyPem = KeyEditorPrivateKey,
|
|
|
|
// Not trimmed and not emptied: leading or trailing spaces are legitimate in a passphrase, and
|
|
// the record turns an empty one into null on its own.
|
|
Passphrase = KeyEditorPassphrase,
|
|
PublicKey = string.IsNullOrWhiteSpace(KeyEditorPublicKey) ? null : KeyEditorPublicKey.Trim(),
|
|
Notes = string.IsNullOrWhiteSpace(KeyEditorNotes) ? null : KeyEditorNotes,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Whether the host editor has to be dealt with before the sidebar starts another one.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Scoped to the host editor alone, and that scoping is the point: the host editor lives in
|
|
/// <c>HostSidebar</c>, on the Hosts screen, and nothing on the Vault screen shares its column or its
|
|
/// visibility with it. A vault-screen editor being open says nothing about whether it is safe to start
|
|
/// editing a host — the two cannot even be looked at at the same time — so this no longer asks about
|
|
/// them. See <see cref="AVaultEditorIsInTheWay"/> for the reasoning this once shared with them, and why
|
|
/// splitting it was necessary rather than cosmetic: the earlier single check refused every host action
|
|
/// while a key editor sat open on a screen the sidebar was not showing, with a status message naming an
|
|
/// editor the user could not see and no way to reach it without abandoning what they had just started
|
|
/// on the Hosts screen.
|
|
/// </remarks>
|
|
private bool AHostEditorIsInTheWay()
|
|
{
|
|
if (IsEditing)
|
|
{
|
|
Status = "Finish or cancel the host you are editing first.";
|
|
}
|
|
|
|
return IsEditing;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether a vault-screen editor has to be dealt with before the rail or another editor opens.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// One editor open at a time on this screen, and the reason is the key editor: it holds a pasted
|
|
/// private key in a bound string for as long as it is open, and only <c>CancelKeyEdit</c> lets go of
|
|
/// it. Letting the rail move the category, or another editor open, with that editor still holding
|
|
/// material would leave a private key in a form nobody can see, with nothing on screen to say it is
|
|
/// there.
|
|
/// </para>
|
|
/// <para>
|
|
/// Refused rather than resolved by closing the open editor, because closing it would silently discard
|
|
/// what was typed there — and in the key editor that is a pasted private key the user may have nowhere
|
|
/// else. One sentence and one click is the cheaper of the two.
|
|
/// </para>
|
|
/// <para>
|
|
/// Does not ask about <see cref="IsEditing"/>. The host editor is a different screen's business now —
|
|
/// see <see cref="AHostEditorIsInTheWay"/> — and asking about it here is what used to leave three
|
|
/// quarters of this screen inert with a status line pointing at an editor the user was not looking at.
|
|
/// </para>
|
|
/// </remarks>
|
|
private bool AVaultEditorIsInTheWay()
|
|
{
|
|
Status = (IsEditingKey, IsEditingCredential, IsGeneratingKey, IsEditingObjectStore, IsEditingTag)
|
|
switch
|
|
{
|
|
(true, _, _, _, _) => "Finish or cancel the SSH key you are editing first.",
|
|
(_, true, _, _, _) => "Finish or cancel the credential you are editing first.",
|
|
(_, _, true, _, _) => "Finish or cancel the key you are generating first.",
|
|
(_, _, _, true, _) => "Finish or cancel the bucket you are editing first.",
|
|
(_, _, _, _, true) => "Finish or cancel the tag you are editing first.",
|
|
_ => Status,
|
|
};
|
|
|
|
return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore
|
|
|| IsEditingTag;
|
|
}
|
|
|
|
private void ClearKeyEditor()
|
|
{
|
|
KeyEditorLabel = string.Empty;
|
|
KeyEditorPrivateKey = string.Empty;
|
|
KeyEditorPassphrase = string.Empty;
|
|
KeyEditorPublicKey = string.Empty;
|
|
KeyEditorNotes = string.Empty;
|
|
}
|
|
|
|
private void ClearCredentialEditor()
|
|
{
|
|
CredentialEditorLabel = string.Empty;
|
|
CredentialEditorUsername = string.Empty;
|
|
CredentialEditorPassword = string.Empty;
|
|
CredentialEditorNotes = string.Empty;
|
|
}
|
|
|
|
private async Task LoadConflictsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Conflicts.Clear();
|
|
|
|
foreach (var notice in notices)
|
|
{
|
|
Conflicts.Add(new ConflictRowViewModel(notice));
|
|
}
|
|
|
|
OnPropertyChanged(nameof(HasConflicts));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Deliberately reports the things a user has to act on rather than a count of successes. A pass that
|
|
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
|
|
/// of recording those is that somebody sees them.
|
|
/// </remarks>
|
|
/// <remarks>
|
|
/// Movement and attention only — deliberately not failure. A background pass that announced every
|
|
/// unreachable vault would be a socket error on screen once a minute, which is the thing
|
|
/// <see cref="AutoSyncAsync"/>'s catch block exists to avoid; the caller records
|
|
/// <see cref="LastSyncFailed"/> instead, and the titlebar stops claiming to be up to date. Pressing
|
|
/// Sync reports the failure in full, because somebody who pressed it is waiting for an answer.
|
|
/// </remarks>
|
|
/// <remarks>
|
|
/// The item counts rather than the raw ones. Every user action queues a log entry a moment after the
|
|
/// action's own status message, and this machine reads its own entries back on the next pull — so a
|
|
/// rule written against the raw numbers would overwrite that message after every single save, which is
|
|
/// exactly what it did until the report learned to tell the two apart.
|
|
/// </remarks>
|
|
private static bool IsWorthReporting(IReadOnlyList<VaultSyncReport> reports) =>
|
|
reports.Any(vault => vault.Succeeded
|
|
&& (vault.Report!.PulledItems > 0
|
|
|| vault.Report.PushedItems > 0
|
|
|| vault.Report.NeedsAttention
|
|
|
|
// A pass that had to start over says so even when it pulled nothing, which is the one
|
|
// place this rule is broken deliberately. A machine that silently re-read a whole vault
|
|
// has had something happen to it, and the alternative is that nobody ever finds out.
|
|
|| vault.Report.ResyncedFromStart));
|
|
|
|
/// <remarks>
|
|
/// Counts are summed across vaults, and a failure is named <em>with its reason</em>. Both halves
|
|
/// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a
|
|
/// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them
|
|
/// costs nothing.
|
|
/// </remarks>
|
|
private static string Describe(IReadOnlyList<VaultSyncReport> reports)
|
|
{
|
|
var failed = reports
|
|
.Where(vault => !vault.Succeeded)
|
|
.Select(vault => $"{vault.Name} ({vault.Failure?.Message})")
|
|
.ToList();
|
|
|
|
var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList();
|
|
|
|
var line = succeeded.Count switch
|
|
{
|
|
0 => string.Empty,
|
|
1 => Describe(succeeded[0]),
|
|
_ => DescribeMany(succeeded),
|
|
};
|
|
|
|
if (failed.Count == 0)
|
|
{
|
|
return line.Length == 0 ? "Nothing to synchronise." : line;
|
|
}
|
|
|
|
var names = string.Join("; ", failed);
|
|
|
|
return line.Length == 0
|
|
? $"Could not synchronise {names}."
|
|
: $"{line} Could not synchronise {names}.";
|
|
}
|
|
|
|
private static string DescribeMany(List<SyncReport> reports)
|
|
{
|
|
var pulled = reports.Sum(report => report.Pulled);
|
|
var pushed = reports.Sum(report => report.Pushed);
|
|
var attention = reports.Count(report => report.NeedsAttention);
|
|
|
|
var line = pulled == 0 && pushed == 0
|
|
? $"Already up to date across {reports.Count} vaults."
|
|
: $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out.";
|
|
|
|
return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list.";
|
|
}
|
|
|
|
private static string Describe(SyncReport report)
|
|
{
|
|
// Said first, and in both branches, because it is the explanation for the numbers after it. A pass
|
|
// reporting "214 in" on a vault nobody has touched all week reads as something having gone wrong;
|
|
// this is what actually happened, and it needs nothing from the reader.
|
|
var replayed = report.ResyncedFromStart
|
|
? "The server no longer recognised this machine's position, so the keychain was read again from "
|
|
+ "the beginning. "
|
|
: string.Empty;
|
|
|
|
if (!report.NeedsAttention)
|
|
{
|
|
return replayed + (report.Pulled == 0 && report.Pushed == 0
|
|
? "Already up to date."
|
|
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.");
|
|
}
|
|
|
|
var notes = new List<string>();
|
|
|
|
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
|
|
// would send someone looking through the wrong list for something that was not there.
|
|
if (report.Resurrected > 0)
|
|
{
|
|
notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
|
|
}
|
|
|
|
if (report.DeletesAbandoned > 0)
|
|
{
|
|
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
|
|
}
|
|
|
|
if (report.Parked > 0)
|
|
{
|
|
notes.Add($"{report.Parked} change(s) were refused and need attention");
|
|
}
|
|
|
|
if (report.Unreadable > 0)
|
|
{
|
|
notes.Add($"{report.Unreadable} item(s) could not be decrypted");
|
|
}
|
|
|
|
if (report.RekeyRequired)
|
|
{
|
|
notes.Add("this keychain was rekeyed and your access needs re-issuing");
|
|
}
|
|
|
|
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
|
|
}
|
|
|
|
private async Task RunAsync(string busyMessage, Func<Task> work)
|
|
{
|
|
if (IsBusy)
|
|
{
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
Status = busyMessage;
|
|
|
|
try
|
|
{
|
|
await work().ConfigureAwait(true);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
Status = "Cancelled.";
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
Status = exception.Message;
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
}
|
|
}
|
|
|
|
partial void OnSelectedHostChanged(HostRowViewModel? value)
|
|
{
|
|
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
|
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
|
OnPropertyChanged(nameof(ShowsConnectBar));
|
|
|
|
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
|
|
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
|
|
// other.
|
|
SelectedSidebarRow = value;
|
|
|
|
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one direction that needs a decision. A host selection is the application's selection and passes
|
|
/// straight through; a heading is not, and is turned back into whatever was selected before it, so that
|
|
/// clicking a group name neither breaks the buttons at the foot of the sidebar nor leaves a row
|
|
/// highlighted that none of them act on.
|
|
/// </para>
|
|
/// <para>
|
|
/// A null is left alone rather than cleared through. It arrives from the <c>ListBox</c>'s own answer to
|
|
/// the <c>Reset</c> that rebuilding the list raises — which happens on every filter keystroke and every
|
|
/// background sync — and treating that as the user deselecting would take the selection away from under
|
|
/// them once a minute. Deliberate clearing is done by <see cref="RebuildVisibleHosts"/>, which sets
|
|
/// <see cref="SelectedHost"/> itself.
|
|
/// </para>
|
|
/// </remarks>
|
|
partial void OnSelectedSidebarRowChanged(ISidebarRow? value)
|
|
{
|
|
switch (value)
|
|
{
|
|
case HostRowViewModel host:
|
|
SelectedHost = host;
|
|
break;
|
|
|
|
case SidebarGroupHeader:
|
|
SelectedSidebarRow = SelectedHost;
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
|
|
{
|
|
DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId);
|
|
}
|
|
|
|
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
|
{
|
|
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
|
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
|
|
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
|
OnPropertyChanged(nameof(ShowsHostActions));
|
|
OnPropertyChanged(nameof(ShowsGroupActions));
|
|
OnPropertyChanged(nameof(ShowsItemActions));
|
|
}
|
|
|
|
partial void OnEditingGroupIdChanged(Guid? value) => OnPropertyChanged(nameof(GroupSaveLabel));
|
|
|
|
/// <summary>
|
|
/// Takes the question away when the selection it was asked about has moved on.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Compared by entity id rather than by row, and that is the whole point of the method. A reload
|
|
/// replaces every row object in the list, so a background pass a minute after the question would
|
|
/// otherwise take the card away from under somebody still reading it — while a click onto a different
|
|
/// item, which is the case that actually needs handling, leaves an armed deletion pointing at something
|
|
/// nobody is looking at any more.
|
|
/// </remarks>
|
|
private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId)
|
|
{
|
|
if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId)
|
|
{
|
|
PendingDeletion = null;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the
|
|
/// hosts already in memory, with no decryption and nothing on disk behind it.
|
|
/// </remarks>
|
|
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
|
|
|
|
/// <summary>Adds the tag rows to the table, when the table is showing them.</summary>
|
|
/// <remarks>
|
|
/// Its own method purely for length: <see cref="RebuildVaultItems"/> is one <c>if</c> per kind and a
|
|
/// fifth took it past what this project lets a method be. Split at the newest arm rather than the
|
|
/// prettiest place, so the diff that added it is the diff that moved it.
|
|
/// </remarks>
|
|
private void AddTagRows()
|
|
{
|
|
if (Section is not (VaultSection.All or VaultSection.Tags))
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var tag in Tags)
|
|
{
|
|
VaultItems.Add(new VaultItemRowViewModel(
|
|
VaultItemKind.Tag,
|
|
tag.EntityId,
|
|
tag.Label,
|
|
"TAG",
|
|
tag.Description,
|
|
tag.Badge,
|
|
tag.HasUnsyncedChanges));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refills the vault table from the typed lists.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins.
|
|
/// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved
|
|
/// them would put a pin nobody created between two things somebody did.
|
|
/// </remarks>
|
|
private void RebuildVaultItems()
|
|
{
|
|
var selectedId = SelectedVaultItem?.EntityId;
|
|
|
|
VaultItems.Clear();
|
|
|
|
if (Section is VaultSection.All or VaultSection.Keys)
|
|
{
|
|
foreach (var key in Keys)
|
|
{
|
|
VaultItems.Add(new VaultItemRowViewModel(
|
|
VaultItemKind.Key,
|
|
key.EntityId,
|
|
key.Label,
|
|
"SSH KEY",
|
|
key.Description,
|
|
key.Badge,
|
|
key.HasUnsyncedChanges));
|
|
}
|
|
}
|
|
|
|
if (Section is VaultSection.All or VaultSection.Credentials)
|
|
{
|
|
foreach (var credential in Credentials)
|
|
{
|
|
VaultItems.Add(new VaultItemRowViewModel(
|
|
VaultItemKind.Credential,
|
|
credential.EntityId,
|
|
credential.Label,
|
|
"PASSWORD",
|
|
credential.Description,
|
|
credential.Badge,
|
|
credential.HasUnsyncedChanges));
|
|
}
|
|
}
|
|
|
|
AddTagRows();
|
|
|
|
if (Section is VaultSection.All or VaultSection.Buckets)
|
|
{
|
|
foreach (var store in ObjectStores)
|
|
{
|
|
VaultItems.Add(new VaultItemRowViewModel(
|
|
VaultItemKind.ObjectStore,
|
|
store.EntityId,
|
|
store.Label,
|
|
"BUCKET",
|
|
store.Description,
|
|
store.Badge,
|
|
store.HasUnsyncedChanges));
|
|
}
|
|
}
|
|
|
|
// The selection survives a reload, as every other list's does, and for the same reason: a background
|
|
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
|
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
|
|
|
|
OnPropertyChanged(nameof(SectionSummary));
|
|
OnPropertyChanged(nameof(HasVaultItems));
|
|
OnPropertyChanged(nameof(TotalItemCount));
|
|
OnPropertyChanged(nameof(EmptySectionMessage));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Mapped onto the typed selection rather than mirrored into it, and only for the kind selected: leaving
|
|
/// the other two alone means switching category and back does not clear what an editor was pointing at.
|
|
/// </remarks>
|
|
partial void OnSelectedVaultItemChanged(VaultItemRowViewModel? value)
|
|
{
|
|
OnPropertyChanged(nameof(HasSelectedVaultItem));
|
|
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
|
OnPropertyChanged(nameof(SelectedItemIsKey));
|
|
OnPropertyChanged(nameof(SelectedDetailHeading));
|
|
OnPropertyChanged(nameof(ShowsItemActions));
|
|
|
|
// Every kind this table can delete, because one selection covers all of their lists.
|
|
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
|
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
|
DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, value?.EntityId);
|
|
DisarmIfAimedElsewhere(DeletionTarget.Tag, value?.EntityId);
|
|
|
|
switch (value?.Kind)
|
|
{
|
|
case VaultItemKind.Key:
|
|
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == value.EntityId);
|
|
break;
|
|
|
|
case VaultItemKind.Credential:
|
|
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == value.EntityId);
|
|
break;
|
|
|
|
case VaultItemKind.ObjectStore:
|
|
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == value.EntityId);
|
|
break;
|
|
|
|
case VaultItemKind.Tag:
|
|
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == value.EntityId);
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
|
|
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
|
|
/// the DELETE the user could see belonged to an item they were no longer looking at.
|
|
/// </remarks>
|
|
partial void OnIsEditingTagChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
|
|
|
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
|
|
|
|
partial void OnUnreadableItemsChanged(int value)
|
|
{
|
|
OnPropertyChanged(nameof(HasUnreadableItems));
|
|
OnPropertyChanged(nameof(UnreadableSummary));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Both, on every change. A selector that highlights the showing section and a column that shows the
|
|
/// selected one are the same fact read from two directions, and raising only the one that became true
|
|
/// would leave the other button lit.
|
|
/// </remarks>
|
|
partial void OnSectionChanged(VaultSection value)
|
|
{
|
|
OnPropertyChanged(nameof(ShowsAll));
|
|
OnPropertyChanged(nameof(ShowsKeys));
|
|
OnPropertyChanged(nameof(ShowsCredentials));
|
|
OnPropertyChanged(nameof(ShowsBuckets));
|
|
OnPropertyChanged(nameof(ShowsTags));
|
|
OnPropertyChanged(nameof(SectionTitle));
|
|
|
|
RebuildVaultItems();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The editing id is set before this flips in every path that opens the editor, and cleared after it
|
|
/// flips back in every path that closes one, so this notification always observes the pair in a
|
|
/// consistent state.
|
|
/// </remarks>
|
|
partial void OnIsEditingChanged(bool value)
|
|
{
|
|
OnPropertyChanged(nameof(CanForgetHostKey));
|
|
OnPropertyChanged(nameof(ShowsHostActions));
|
|
|
|
DisarmOnceAnEditorIsOpen(value);
|
|
}
|
|
|
|
partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
|
|
|
partial void OnIsGeneratingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
|
|
|
partial void OnGenerateAlgorithmChanged(SshKeyAlgorithm value)
|
|
{
|
|
OnPropertyChanged(nameof(GeneratesEd25519));
|
|
OnPropertyChanged(nameof(GeneratesRsa));
|
|
}
|
|
|
|
partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
|
|
|
partial void OnIsEditingObjectStoreChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
|
|
|
/// <summary>
|
|
/// Takes the question away when an editor opens over the pane it was asked in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The sidebar's confirmation replaces the buttons that could open the host editor, so that half cannot
|
|
/// happen; the vault screen's Add buttons stay on screen beside the detail pane, so that half can. One
|
|
/// rule for both, rather than a guard on the three commands that would have to be remembered by the
|
|
/// fourth.
|
|
/// </remarks>
|
|
private void DisarmOnceAnEditorIsOpen(bool opened)
|
|
{
|
|
if (opened)
|
|
{
|
|
PendingDeletion = null;
|
|
}
|
|
}
|
|
|
|
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
|
OnPropertyChanged(nameof(HasPendingHostKey));
|
|
|
|
partial void OnHostKeyMismatchChanged(string? value) =>
|
|
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
|
}
|