Public Access
Offer to bring the keys an ssh_config points at
An import that recorded a key path and left every host asking for a password was an import whose result did not connect. The answer to that was a manual paste per key, which is the sort of thing people do once and then stop importing. So there is a tick, and it starts off. With it off nothing changes: an IdentityFile becomes a note and the host asks for a password. With it on, IMPORT reads each host's first IdentityFile out of ~/.ssh, stores it in the vault encrypted like any other key, and binds the host to it. Three things about how it is drawn are load-bearing rather than tidy. It is a default nobody arrives at by accident. The sentence beside it names the directory rather than saying "your keys", because that is what somebody is agreeing to. And nothing is read during SCAN — tick it, read what it says, untick it, and no private key has been opened. This is the only place the application opens key material out of a directory the user did not point at file by file, and the whole of what makes that acceptable is that it took a deliberate press. One vault key per file, however many entries named it: an ssh_config pointing twelve hosts at one id_ed25519 is the ordinary shape, and twelve copies would be twelve things to rotate and eleven to forget. A file whose material is already in the keychain is bound to rather than stored again, which is what makes running the import twice harmless. What cannot be read off a disk is a passphrase, so a protected key arrives without one — and the report under the button names those files rather than leaving a host to fail at connect time with a message about a malformed key. Telling them apart means decoding for OpenSSH's own container, whose cipher name is the first field inside the base64 rather than anything in the armour, and that is the format ssh-keygen has written by default for years. The 88 base64 characters it decodes need 66 bytes, not 64: with the smaller span every protected key came back unprotected, which the tests now pin. A path that is not on this machine leaves its host imported and unbound, exactly as it would have been with the tick off, and is named in the same report. A config carried from another machine is the ordinary case, not an error.
This commit is contained in:
@@ -6,6 +6,26 @@ using DodoSSH.Client.Import;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One host an import is about to store, with the key file it named if that is coming too.</summary>
|
||||
/// <remarks>
|
||||
/// The key travels beside the host rather than inside it because the two are separate vault items: a host
|
||||
/// carries an <c>SshKeyId</c>, and the id does not exist until the key has been written. So the screen says
|
||||
/// which file a host named and the vault decides what id that file ends up with — see
|
||||
/// <c>VaultViewModel.ImportHostsAsync</c>, which is where one key per file is enforced.
|
||||
/// </remarks>
|
||||
/// <param name="Host">The host as it would be stored, with no binding on it yet.</param>
|
||||
/// <param name="Key">The key read off the disk, or null when none was asked for or none could be read.</param>
|
||||
/// <param name="KeyPath">
|
||||
/// The file <paramref name="Key"/> came out of. It is what two hosts naming one key are compared on, so it
|
||||
/// travels even though nothing stores it.
|
||||
/// </param>
|
||||
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
||||
|
||||
/// <summary>What an import stored.</summary>
|
||||
/// <param name="Hosts">Hosts written.</param>
|
||||
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
||||
internal sealed record ImportOutcome(int Hosts, int Keys);
|
||||
|
||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||
/// <remarks>
|
||||
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
|
||||
@@ -16,10 +36,11 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly ImportedHost host;
|
||||
|
||||
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
|
||||
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent, bool importsItsKey)
|
||||
{
|
||||
this.host = host;
|
||||
AlreadyPresent = alreadyPresent;
|
||||
this.importsItsKey = importsItsKey && HasKeyFile;
|
||||
|
||||
// A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
|
||||
// for one machine is a thing people genuinely want — but it should take a click rather than be the
|
||||
@@ -40,17 +61,36 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
|
||||
internal bool HasBadge => AlreadyPresent;
|
||||
|
||||
/// <summary>Whether this row's <c>ssh_config</c> entry named a key at all.</summary>
|
||||
/// <remarks>
|
||||
/// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than
|
||||
/// asked of <see cref="ImportedHost"/> at each call site so that "this row has a key file" is one
|
||||
/// sentence in one place.
|
||||
/// </remarks>
|
||||
internal bool HasKeyFile => host.IdentityFiles.Count > 0;
|
||||
|
||||
/// <summary>What this row's <c>IdentityFile</c> named, or empty where it named none.</summary>
|
||||
internal string KeyPath => HasKeyFile ? host.IdentityFiles[0] : string.Empty;
|
||||
|
||||
/// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
|
||||
/// <remarks>
|
||||
/// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
|
||||
/// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
|
||||
/// connection that does not work.
|
||||
/// <para>
|
||||
/// "a key on disk" rather than "a key" when the material is staying where it is, because nothing is
|
||||
/// imported then: the path is recorded and the host will ask for a password until somebody binds it to a
|
||||
/// keychain key. Saying "key" there would promise a connection that does not work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ With the tick on it says the opposite, and has to: the same row now means a key that will be read,
|
||||
/// stored and bound, and leaving it reading "on disk" would understate the one thing on this screen
|
||||
/// somebody explicitly agreed to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string Authentication => host.IdentityFiles.Count switch
|
||||
internal string Authentication => (host.IdentityFiles.Count, ImportsItsKey) switch
|
||||
{
|
||||
0 => "password",
|
||||
1 => $"a key on disk · {host.IdentityFiles[0]}",
|
||||
var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
|
||||
(0, _) => "password",
|
||||
(_, true) => $"a key, imported · {host.IdentityFiles[0]}",
|
||||
(1, _) => $"a key on disk · {host.IdentityFiles[0]}",
|
||||
var (count, _) => $"{count} keys on disk · {host.IdentityFiles[0]}",
|
||||
};
|
||||
|
||||
internal bool HasWarnings => host.Warnings.Count > 0;
|
||||
@@ -59,6 +99,17 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSelected;
|
||||
|
||||
/// <summary>Whether the import will read this row's key file as well as its host.</summary>
|
||||
/// <remarks>
|
||||
/// Set from the one tick above the list rather than owned per row. A key file is very often shared by a
|
||||
/// dozen entries, so a per-row choice would be a dozen ticks deciding one thing — and the decision being
|
||||
/// made is about reading <c>~/.ssh</c> at all, which is a decision about the directory rather than about
|
||||
/// any host in it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(Authentication))]
|
||||
private bool importsItsKey;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -72,10 +123,19 @@ internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
/// interesting question is not "can it be parsed" but "which of these did you actually want".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
|
||||
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
|
||||
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
|
||||
/// for making one deliberately, and pasting an existing one is a deliberate act too.
|
||||
/// ◆ <b>Private keys are read only if somebody ticks the box, and it starts unticked.</b> Pulling a
|
||||
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate, and
|
||||
/// doing it as a side effect of "import my config" would be the wrong default — so it stays a default nobody
|
||||
/// gets by accident, and the sentence beside the tick names the directory it will read. What changed is that
|
||||
/// it is now <em>possible</em>: an import that recorded a path and left every host asking for a password was
|
||||
/// an import whose result did not connect, and the answer to that was a manual paste per key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With the tick on, each host's first <c>IdentityFile</c> is read, stored as a keychain key and bound to
|
||||
/// the host. One key per file however many hosts named it, and a file already in the keychain is bound to
|
||||
/// rather than stored twice; both of those live in <c>VaultViewModel.ImportHostsAsync</c>. What cannot be
|
||||
/// read off a disk is a passphrase, so a protected key arrives without one and the screen says which — see
|
||||
/// <see cref="KeyReport"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
|
||||
@@ -97,10 +157,47 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the import should also read the key files the config names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Off, and it has to be the sort of off that survives somebody not reading the screen. This is the only
|
||||
/// control in the application that reads private key material out of a directory the user did not point
|
||||
/// at file by file, and the whole of what makes that acceptable is that it took a deliberate press.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Not persisted in <c>ClientSettings</c>, deliberately. A remembered "yes" would make the next import —
|
||||
/// possibly on another machine, possibly of a colleague's config — read <c>~/.ssh</c> without asking
|
||||
/// again, which is the answer to a question that was never put twice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool importsKeys;
|
||||
|
||||
/// <summary>What happened to the key files, one line each. Empty until an import has run.</summary>
|
||||
/// <remarks>
|
||||
/// Reported after the fact rather than previewed, because previewing means reading them — and a screen
|
||||
/// that read every key in order to tell you it had not imported them would be doing the thing the tick
|
||||
/// exists to gate. So the list is what the import found: which were stored, which are protected by a
|
||||
/// passphrase this cannot know, and which were not there at all.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<string> KeyReport { get; } = [];
|
||||
|
||||
internal bool HasRows => Rows.Count > 0;
|
||||
|
||||
internal bool HasWarnings => Warnings.Count > 0;
|
||||
|
||||
internal bool HasKeyReport => KeyReport.Count > 0;
|
||||
|
||||
/// <summary>Whether anything in the scan named a key file at all.</summary>
|
||||
/// <remarks>
|
||||
/// The tick is drawn only when it would do something. A config of forty password hosts has no keys to
|
||||
/// offer, and a permanently visible offer to read <c>~/.ssh</c> on a screen where it would read nothing
|
||||
/// is a control that teaches people to ignore it.
|
||||
/// </remarks>
|
||||
internal bool HasKeyFiles => Rows.Any(row => row.HasKeyFile);
|
||||
|
||||
internal int SelectedCount => Rows.Count(row => row.IsSelected);
|
||||
|
||||
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
||||
@@ -111,6 +208,7 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
{
|
||||
Rows.Clear();
|
||||
Warnings.Clear();
|
||||
KeyReport.Clear();
|
||||
HasScanned = false;
|
||||
|
||||
if (!locator.Exists)
|
||||
@@ -128,7 +226,7 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
|
||||
foreach (var host in import.Hosts)
|
||||
{
|
||||
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
|
||||
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host), ImportsKeys));
|
||||
}
|
||||
|
||||
foreach (var warning in import.Warnings)
|
||||
@@ -157,7 +255,12 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores the ticked hosts.</summary>
|
||||
/// <summary>Stores the ticked hosts, and the keys they name if that was asked for.</summary>
|
||||
/// <remarks>
|
||||
/// The key files are read here rather than during the scan, and that ordering is the tick's whole
|
||||
/// meaning: until this press nothing has opened a private key, so changing your mind after scanning
|
||||
/// costs nothing and leaves nothing read.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ImportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -173,18 +276,24 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
|
||||
try
|
||||
{
|
||||
var imported = await vault
|
||||
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
KeyReport.Clear();
|
||||
|
||||
var requests = chosen.Select(BuildRequest).ToList();
|
||||
|
||||
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
||||
// pressing the button twice harmless and visible rather than harmless and confusing.
|
||||
foreach (var row in Rows.ToList())
|
||||
{
|
||||
Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
|
||||
Rows[Rows.IndexOf(row)] =
|
||||
new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host), ImportsKeys);
|
||||
}
|
||||
|
||||
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
|
||||
// This screen's own sentence rather than the vault's. The vault writes one too and then pushes,
|
||||
// and the push overwrites it with a sync report — which is right on a screen somebody is about
|
||||
// to leave, and wrong on the one they are still standing on.
|
||||
Status = Describe(outcome);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -193,6 +302,77 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What the import did, in this screen's own words.</summary>
|
||||
/// <remarks>
|
||||
/// The keys are counted separately and named only when there are any, because reading key material is
|
||||
/// the half of this somebody agreed to rather than the half they asked for. The count is files, not
|
||||
/// hosts: a dozen entries sharing one key is one key, and saying twelve would be describing the config
|
||||
/// rather than what was stored.
|
||||
/// </remarks>
|
||||
private static string Describe(ImportOutcome outcome)
|
||||
{
|
||||
var material = outcome.Keys switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => " 1 private key came with them, and every host that named it is bound to it.",
|
||||
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
||||
};
|
||||
|
||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a ticked row into what the vault stores, reading its key file where that was agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The first <c>IdentityFile</c> only, which is the same one <see cref="ImportedHost.ToSecret"/> records
|
||||
/// as a directive. A host may list several and ssh tries each in turn; a keychain host binds one key, so
|
||||
/// importing all of them would be storing keys nothing is bound to and calling it authentication.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every outcome is a line in <see cref="KeyReport"/> rather than a failure, including the good one. A
|
||||
/// key that could not be read leaves the host importable and unbound, which is exactly what the import
|
||||
/// did for every host before the tick existed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private ImportedHostRequest BuildRequest(ImportRowViewModel row)
|
||||
{
|
||||
var host = row.Host.ToSecret();
|
||||
|
||||
if (!ImportsKeys || !row.HasKeyFile)
|
||||
{
|
||||
return new ImportedHostRequest(host, Key: null, KeyPath: null);
|
||||
}
|
||||
|
||||
var identity = locator.ReadIdentity(row.KeyPath);
|
||||
|
||||
if (!identity.WasRead)
|
||||
{
|
||||
KeyReport.Add($"{row.Alias}: {identity.Path} was not imported — {identity.Failure}.");
|
||||
|
||||
return new ImportedHostRequest(host, Key: null, KeyPath: null);
|
||||
}
|
||||
|
||||
// Named for the file rather than for the host, because one file is very often a dozen hosts and a
|
||||
// key called "web-01" that four other machines also use is a name that misleads on five screens.
|
||||
var key = new SshKeySecret
|
||||
{
|
||||
Label = Path.GetFileName(identity.Path),
|
||||
PrivateKeyPem = identity.PrivateKeyPem,
|
||||
PublicKey = identity.PublicKey,
|
||||
Notes = $"Imported from {identity.Path} with {locator.ConfigPath}.",
|
||||
};
|
||||
|
||||
KeyReport.Add(identity.IsEncrypted
|
||||
? $"{row.Alias}: {identity.Path} was imported and is protected by a passphrase. Nothing on "
|
||||
+ "disk says what it is, so add it to the key on the keychain screen or the host will not "
|
||||
+ "connect."
|
||||
: $"{row.Alias}: {identity.Path} was imported.");
|
||||
|
||||
return new ImportedHostRequest(host, key, identity.Path);
|
||||
}
|
||||
|
||||
/// <summary>Ticks or unticks everything at once.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleAll()
|
||||
@@ -209,6 +389,20 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
|
||||
internal void NoteSelectionChanged() => RaiseListState();
|
||||
|
||||
/// <remarks>
|
||||
/// The rows carry the answer as well as the view model, because each one says what it will authenticate
|
||||
/// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a
|
||||
/// property of the screen it is in without a parent binding, and that is markup for something the list
|
||||
/// already has to be walked for.
|
||||
/// </remarks>
|
||||
partial void OnImportsKeysChanged(bool value)
|
||||
{
|
||||
foreach (var row in Rows)
|
||||
{
|
||||
row.ImportsItsKey = value && row.HasKeyFile;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Matched on where a host points rather than on what it is called. Two entries with different aliases
|
||||
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
|
||||
@@ -243,6 +437,8 @@ internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLoc
|
||||
{
|
||||
OnPropertyChanged(nameof(HasRows));
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
OnPropertyChanged(nameof(HasKeyFiles));
|
||||
OnPropertyChanged(nameof(HasKeyReport));
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(ImportLabel));
|
||||
}
|
||||
|
||||
@@ -5782,14 +5782,15 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores several hosts at once, the way one save does.
|
||||
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
|
||||
/// too.
|
||||
/// </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.
|
||||
/// through the same repositories, 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 or a key 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
|
||||
@@ -5800,47 +5801,142 @@ internal sealed partial class VaultViewModel(
|
||||
/// 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>
|
||||
/// <para>
|
||||
/// ◆ <b>One vault key per file, however many hosts named it.</b> An <c>ssh_config</c> pointing twelve
|
||||
/// entries at <c>~/.ssh/id_ed25519</c> is the ordinary shape, and twelve copies of one private key would
|
||||
/// be twelve things to rotate and eleven to forget — which is the argument <c>HostSecret.SshKeyId</c>
|
||||
/// already makes for referencing a key rather than embedding it. The path is what identifies a file here,
|
||||
/// because it is the only thing the config gave that two entries can be compared on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A key whose material is already in the keychain is bound to rather than stored again</b>, which is
|
||||
/// what makes running the import twice harmless. Compared on the armour verbatim, as the codec stores it:
|
||||
/// a re-import of the same file is byte-identical, and anything that is not is a different key whatever
|
||||
/// it is called.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<int> ImportHostsAsync(
|
||||
IReadOnlyList<HostSecret> hosts,
|
||||
internal async Task<ImportOutcome> ImportHostsAsync(
|
||||
IReadOnlyList<ImportedHostRequest> requests,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hosts);
|
||||
ArgumentNullException.ThrowIfNull(requests);
|
||||
|
||||
var imported = 0;
|
||||
var hosts = 0;
|
||||
var keys = 0;
|
||||
var refused = 0;
|
||||
|
||||
await RunAsync(
|
||||
hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…",
|
||||
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
||||
async () =>
|
||||
{
|
||||
foreach (var host in hosts)
|
||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||
// front: the second host naming a file has to land on the id the first one produced, and
|
||||
// nothing is reloaded until the batch finishes.
|
||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var request in requests)
|
||||
{
|
||||
if (!host.TryValidate(out _))
|
||||
if (!request.Host.TryValidate(out _))
|
||||
{
|
||||
refused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (created)
|
||||
{
|
||||
keys++;
|
||||
}
|
||||
|
||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||
|
||||
await session.Hosts
|
||||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
imported++;
|
||||
hosts++;
|
||||
}
|
||||
|
||||
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}";
|
||||
Status = DescribeImport(hosts, keys, refused);
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return imported;
|
||||
return new ImportOutcome(hosts, keys);
|
||||
}
|
||||
|
||||
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
||||
/// <returns>
|
||||
/// The key to bind to, and whether storing it was what put it there. The second half is what the count
|
||||
/// reported afterwards is about: binding to a key the keychain already held reads nothing off a disk and
|
||||
/// must not be reported as having.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// The keychain is searched as well as this batch's own map, and both are needed. The map answers within
|
||||
/// one run, where nothing has been reloaded yet; the keychain answers for a run an hour ago, which is
|
||||
/// what makes importing the same config twice leave one key rather than two.
|
||||
/// </remarks>
|
||||
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
|
||||
ImportedHostRequest request,
|
||||
Dictionary<string, Guid> bound,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.Key is not { } key || request.KeyPath is not { } path)
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
if (bound.TryGetValue(path, out var already))
|
||||
{
|
||||
return (already, false);
|
||||
}
|
||||
|
||||
if (Keys.FirstOrDefault(row => string.Equals(
|
||||
row.Key.PrivateKeyPem, key.PrivateKeyPem, StringComparison.Ordinal)) is { } existing)
|
||||
{
|
||||
bound[path] = existing.EntityId;
|
||||
|
||||
return (existing.EntityId, false);
|
||||
}
|
||||
|
||||
if (!key.TryValidate(out _))
|
||||
{
|
||||
return (null, false);
|
||||
}
|
||||
|
||||
var id = await session.SshKeys
|
||||
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
bound[path] = id;
|
||||
|
||||
return (id, true);
|
||||
}
|
||||
|
||||
/// <summary>What the import did, in one sentence.</summary>
|
||||
/// <remarks>
|
||||
/// The keys are named separately from the hosts and only when there are any, because importing key
|
||||
/// material is the half of this somebody agreed to rather than the half they asked for — folded into
|
||||
/// "imported 12 hosts" it would be the one number worth saying out loud, said quietly.
|
||||
/// </remarks>
|
||||
private string DescribeImport(int hosts, int keys, int refused)
|
||||
{
|
||||
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
|
||||
|
||||
var material = keys switch
|
||||
{
|
||||
0 => string.Empty,
|
||||
1 => " 1 private key came with them.",
|
||||
_ => $" {keys} private keys came with them.",
|
||||
};
|
||||
|
||||
return connection() is null
|
||||
? $"Imported {hosts} host(s). They will sync when you are online.{material}{refusals}"
|
||||
: $"Imported {hosts} host(s).{material}{refusals}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
Reference in New Issue
Block a user