Public Access
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
panel kept showing the previous vault's people under the new name. The read now starts regardless;
the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
Keychain names trim inside their column instead of printing over TYPE, and the Security page's
caption wraps inside its card.
864 lines
39 KiB
C#
864 lines
39 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Import;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>Which of the wizard's three steps is on screen.</summary>
|
|
/// <remarks>
|
|
/// A wizard rather than one screen, unlike <see cref="ImportViewModel"/>, and the reason is what each step
|
|
/// costs. Reading an <c>ssh_config</c> is free and local, so that screen scans the moment it opens and shows
|
|
/// a list. This one cannot: it needs a credential the user has to go and make in somebody else's console,
|
|
/// and it then makes a request over the internet. Those are two different moments, and a screen that mixed
|
|
/// the token box in with the list would ask for the credential again every time somebody came back to look
|
|
/// at the list.
|
|
/// </remarks>
|
|
internal enum HetznerImportStep
|
|
{
|
|
/// <summary>Paste a token. Nothing has been sent yet.</summary>
|
|
Token = 0,
|
|
|
|
/// <summary>The project's servers, and what to do with them. Nothing has been stored yet.</summary>
|
|
Choose = 1,
|
|
|
|
/// <summary>What was stored.</summary>
|
|
Done = 2,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One Hetzner server, as a row somebody decides about.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The address is not a property of the server but of the server <em>and the choice above the list</em>, so
|
|
/// it is pushed down by <see cref="Refresh"/> rather than computed here — the same shape
|
|
/// <see cref="ImportRowViewModel.ImportsItsKey"/> has, and for the same reason: a control that decides one
|
|
/// thing for forty rows belongs above the forty rows.
|
|
/// </remarks>
|
|
internal sealed partial class HetznerRowViewModel(HetznerServer server) : ObservableObject
|
|
{
|
|
private HetznerAddress? resolved;
|
|
|
|
internal HetznerServer Server => server;
|
|
|
|
internal string Name => server.Name;
|
|
|
|
/// <summary>Whether it is running, off, or somewhere in between — a fact of the moment, never stored.</summary>
|
|
internal string Status => server.Status;
|
|
|
|
/// <summary>The plan and where it runs, as one cell.</summary>
|
|
internal string Kind => (server.ServerType, server.Location) switch
|
|
{
|
|
({ Length: > 0 } plan, { Length: > 0 } place) => $"{plan} · {place}",
|
|
({ Length: > 0 } plan, _) => plan,
|
|
(_, { Length: > 0 } place) => place,
|
|
_ => "—",
|
|
};
|
|
|
|
/// <summary>What this row would dial, or an em dash where the address choice leaves it nothing.</summary>
|
|
internal string Address => resolved?.Value ?? "—";
|
|
|
|
/// <summary>Whether the address choice above the list can reach this server at all.</summary>
|
|
/// <remarks>
|
|
/// False is the private-network case: a server on no private network has no private address, and this
|
|
/// import will not quietly fall back to a public one. Such a row cannot be ticked, which is what stops
|
|
/// an import that was asked to stay off the internet from putting a public address in the keychain.
|
|
/// </remarks>
|
|
internal bool CanImport => resolved is not null;
|
|
|
|
/// <summary>Whether the keychain already has a host pointing where this one would.</summary>
|
|
internal bool AlreadyPresent { get; private set; }
|
|
|
|
/// <summary>What had to be assumed to reach that address, if anything.</summary>
|
|
internal string Warning => resolved?.Warning ?? string.Empty;
|
|
|
|
internal bool HasWarning => resolved?.Warning is { Length: > 0 };
|
|
|
|
/// <summary>
|
|
/// The row's own chip: the single most actionable thing true about it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Ordered by what a person has to do something about. "No address" means this row cannot be imported
|
|
/// at all and wins outright; a warning is a fact about where it will connect and wins over "already
|
|
/// here", which is only about whether it is worth ticking. Deliberately parallel to
|
|
/// <see cref="ImportRowViewModel.Meaning"/> — the same three states plus the one that screen cannot
|
|
/// have, because a resolved <c>ssh_config</c> entry always has a hostname.
|
|
/// </remarks>
|
|
internal string Meaning => (CanImport, HasWarning, AlreadyPresent) switch
|
|
{
|
|
(false, _, _) => "no address of that kind",
|
|
(_, true, _) => Warning,
|
|
(_, _, true) => "already here",
|
|
_ => "new host",
|
|
};
|
|
|
|
internal bool IsMeaningWarned => !CanImport || HasWarning;
|
|
|
|
internal bool IsMeaningExisting => CanImport && !HasWarning && AlreadyPresent;
|
|
|
|
internal bool IsMeaningNew => CanImport && !HasWarning && !AlreadyPresent;
|
|
|
|
/// <summary>The address, resolved, or null where there is none of the chosen kind.</summary>
|
|
internal HetznerAddress? Resolved => resolved;
|
|
|
|
[ObservableProperty]
|
|
private bool isSelected;
|
|
|
|
/// <summary>
|
|
/// Re-resolves this row against the address choice above the list.
|
|
/// </summary>
|
|
/// <param name="kind">Which address the import is dialling.</param>
|
|
/// <param name="isAlreadyPresent">Whether the keychain already holds a host at a given address.</param>
|
|
/// <param name="resetSelection">
|
|
/// Whether to put the tick back to its default. True on a fresh scan and after an import — where the
|
|
/// question "which of these do you want" is being put again — and false when only the address choice
|
|
/// moved, because throwing away somebody's ticks because they changed a dropdown would be its own bug.
|
|
/// A row that has lost its address is unticked either way; there is nothing to import.
|
|
/// </param>
|
|
internal void Refresh(
|
|
HetznerAddressKind kind,
|
|
Func<HetznerAddress, bool> isAlreadyPresent,
|
|
bool resetSelection)
|
|
{
|
|
resolved = server.Resolve(kind);
|
|
AlreadyPresent = resolved is not null && isAlreadyPresent(resolved);
|
|
|
|
if (resetSelection)
|
|
{
|
|
// A server the keychain already points at starts unticked, exactly as the ssh_config import's
|
|
// rows do: importing it again is allowed — a second bookmark for one machine is a thing people
|
|
// want — but it should take a click rather than be the default.
|
|
IsSelected = CanImport && !AlreadyPresent;
|
|
}
|
|
else if (!CanImport)
|
|
{
|
|
IsSelected = false;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(Address));
|
|
OnPropertyChanged(nameof(CanImport));
|
|
OnPropertyChanged(nameof(AlreadyPresent));
|
|
OnPropertyChanged(nameof(Warning));
|
|
OnPropertyChanged(nameof(HasWarning));
|
|
OnPropertyChanged(nameof(Meaning));
|
|
OnPropertyChanged(nameof(IsMeaningWarned));
|
|
OnPropertyChanged(nameof(IsMeaningExisting));
|
|
OnPropertyChanged(nameof(IsMeaningNew));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// One entry in the import's key picker: a key already in the keychain, or none at all.
|
|
/// </summary>
|
|
/// <param name="KeyId">The key, or null for "no key".</param>
|
|
/// <param name="VaultId">The vault that key lives in, or null for the "no key" entry.</param>
|
|
/// <param name="Label">What the picker shows.</param>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>An id rather than the key, because nothing here reads key material.</b> The import writes
|
|
/// <c>HostSecret.SshKeyId</c> and stops; the private half stays where it already was, in the one vault
|
|
/// entry every host that uses it points at. That is the same argument <c>VaultViewModel.ImportHostsAsync</c>
|
|
/// makes for storing one key per file rather than one per host, arrived at from the other end: here there
|
|
/// is no file at all, only a key the keychain has had all along.
|
|
/// </para>
|
|
/// <para>
|
|
/// A sentinel entry for "no key" rather than a null selection, for the reason <c>GroupChoice.None</c> gives:
|
|
/// a picker with nothing selected and a picker meaning "ask for a password" look identical and are not the
|
|
/// same thing — and on this screen the second is a promise about how forty machines will behave.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed record HetznerKeyChoice(Guid? KeyId, Guid? VaultId, string Label)
|
|
{
|
|
/// <summary>The "bind nothing" entry, always first and selected until somebody chooses otherwise.</summary>
|
|
/// <remarks>
|
|
/// The default, and deliberately: pointing an estate at a key is a claim about which key opens those
|
|
/// machines, and Hetzner cannot tell this screen that — the API names a project's keys but has never
|
|
/// held a private half. Guessed wrong it is forty hosts that fail to authenticate with a reason nobody
|
|
/// typed. Chosen, it is one answer to one question the person running the import already knows.
|
|
/// </remarks>
|
|
internal static HetznerKeyChoice None { get; } = new(null, null, "No key — ask for a password");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Importing the servers in a Hetzner Cloud project.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Three steps, and the first two write nothing.</b> Step one takes a token and sends one request; step
|
|
/// two shows what the project holds and lets somebody choose; step three says what was stored. The split is
|
|
/// the same argument <see cref="ImportViewModel"/> makes — a project with forty servers is forty questions,
|
|
/// and only the person reading the list knows which of them they want bookmarked — with one addition this
|
|
/// import has and that one does not: the credential. It has to be fetched from another company's console
|
|
/// before anything here can happen at all, which is a step whether or not it is drawn as one.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The token is never stored.</b> It lives in <see cref="Token"/> while the wizard is open and is cleared
|
|
/// the moment the read is done. Not in the vault, not in settings, not in a log — the next import asks
|
|
/// again. That is the same reasoning <see cref="ImportViewModel.ImportsKeys"/> gives for not remembering its
|
|
/// tick: a remembered answer is an answer to a question that was never put twice, and this one would be a
|
|
/// stored credential capable of listing somebody's whole estate.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Nothing imported here authenticates.</b> Hetzner has never held the private half of an SSH key, so
|
|
/// every host arrives bound to nothing and asks for a password until somebody points it at a key already in
|
|
/// the keychain. The screen says so before the button rather than leaving it to be discovered at connect
|
|
/// time — see <see cref="AuthenticationNote"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class HetznerImportViewModel(
|
|
VaultViewModel vault,
|
|
HetznerCloudClient hetzner,
|
|
Action? onCancel = null) : ObservableObject
|
|
{
|
|
/// <summary>
|
|
/// The user a stock Hetzner Linux image logs in as.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Every image Hetzner publishes puts the project's SSH keys on root and no other account exists yet, so
|
|
/// this is right far more often than it is wrong — and where it is wrong, it is wrong for a whole
|
|
/// estate at once, which is why the box is above the list rather than on each row.
|
|
/// </remarks>
|
|
internal const string DefaultUsername = "root";
|
|
|
|
internal ObservableCollection<HetznerRowViewModel> Rows { get; } = [];
|
|
|
|
/// <summary>What the project could not be made to say, at project level.</summary>
|
|
internal ObservableCollection<string> Warnings { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private HetznerImportStep step = HetznerImportStep.Token;
|
|
|
|
/// <summary>
|
|
/// The Cloud API token, held only for as long as the wizard needs it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A plain bound string, like the passphrase box on the unlock screen, and it carries the same caveat:
|
|
/// a .NET string cannot be zeroed, so what this really promises is that nothing here writes it anywhere
|
|
/// that outlives the process. <see cref="ScanAsync"/> clears it as soon as the read is finished.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string token = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string status = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool isBusy;
|
|
|
|
/// <summary>The login user every imported host gets.</summary>
|
|
[ObservableProperty]
|
|
private string username = DefaultUsername;
|
|
|
|
/// <summary>
|
|
/// The SSH port, as typed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A string rather than an int because a text box passes through "" and "2" on the way to "22", and a
|
|
/// bound int would either refuse the keystroke or silently substitute a number nobody typed. Parsed by
|
|
/// <see cref="Port"/>, which is also what <see cref="IsPortValid"/> reports on.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string portText = HetznerPort.ToString(CultureInfo.InvariantCulture);
|
|
|
|
private const int HetznerPort = 22;
|
|
|
|
[ObservableProperty]
|
|
private HetznerAddressKind addressKind = HetznerAddressKind.PublicIPv4;
|
|
|
|
internal bool IsTokenStep => Step is HetznerImportStep.Token;
|
|
|
|
internal bool IsChooseStep => Step is HetznerImportStep.Choose;
|
|
|
|
internal bool IsDoneStep => Step is HetznerImportStep.Done;
|
|
|
|
/// <summary>"Step 2 of 3", for the header — a wizard that does not say how long it is is a maze.</summary>
|
|
internal string StepLabel => $"STEP {(int)Step + 1} OF 3";
|
|
|
|
/// <summary>What this step is for, in the header, beside <see cref="StepLabel"/>.</summary>
|
|
internal string StepTitle => Step switch
|
|
{
|
|
HetznerImportStep.Token => "Connect to a project",
|
|
HetznerImportStep.Choose => "Choose what to import",
|
|
_ => "Imported",
|
|
};
|
|
|
|
/// <summary>
|
|
/// Which of the three address chips above the list is lit.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Read-only bools beside a command that sets the enum, rather than two-way bound ones, because the
|
|
/// control is a chip that runs <see cref="UseAddressCommand"/> — see <c>HetznerImportScreen.axaml</c>
|
|
/// for why chips rather than radio buttons, which this application has no style for.
|
|
/// </remarks>
|
|
internal bool UsesPublicIPv4 => AddressKind is HetznerAddressKind.PublicIPv4;
|
|
|
|
/// <inheritdoc cref="UsesPublicIPv4" />
|
|
internal bool UsesPublicIPv6 => AddressKind is HetznerAddressKind.PublicIPv6;
|
|
|
|
/// <inheritdoc cref="UsesPublicIPv4" />
|
|
internal bool UsesPrivate => AddressKind is HetznerAddressKind.Private;
|
|
|
|
/// <summary>What the chosen address means for the list, said once above it rather than per row.</summary>
|
|
internal string AddressNote => AddressKind switch
|
|
{
|
|
HetznerAddressKind.PublicIPv4 =>
|
|
"The public IPv4 address, which is how all but the cheapest Hetzner servers are reached. A "
|
|
+ "server with no IPv4 falls back to its IPv6 and the row says so.",
|
|
HetznerAddressKind.PublicIPv6 =>
|
|
"The public IPv6 address. Hetzner routes a whole /64 and the API reports the network rather "
|
|
+ "than an address, so this is the first address in it — where Hetzner's own images put "
|
|
+ "the server, and not something the API states.",
|
|
_ =>
|
|
"The address on the server's own private network, which only reaches it from inside that "
|
|
+ "network. Servers on no private network are not importable under this choice, and this "
|
|
+ "will never quietly fall back to a public address.",
|
|
};
|
|
|
|
internal bool HasRows => Rows.Count > 0;
|
|
|
|
internal bool HasWarnings => Warnings.Count > 0;
|
|
|
|
/// <summary>The port every imported host gets, or null where what is typed is not one.</summary>
|
|
internal int? Port =>
|
|
int.TryParse(PortText, NumberStyles.None, CultureInfo.InvariantCulture, out var port)
|
|
&& port is >= 1 and <= 65535
|
|
? port
|
|
: null;
|
|
|
|
internal bool IsPortValid => Port is not null;
|
|
|
|
internal int SelectedCount => Rows.Count(row => row.IsSelected && row.CanImport);
|
|
|
|
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 SERVER" : $"IMPORT {SelectedCount} SERVERS";
|
|
|
|
/// <summary>How many rows the address choice can actually reach.</summary>
|
|
internal int ImportableCount => Rows.Count(row => row.CanImport);
|
|
|
|
/// <summary>Whether every importable row is ticked — what the header's tick-all box shows.</summary>
|
|
internal bool AllTicked => ImportableCount > 0 && SelectedCount == ImportableCount;
|
|
|
|
/// <summary>Whether IMPORT would do anything.</summary>
|
|
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
|
|
|
|
/// <summary>
|
|
/// Where the imported hosts go — see <see cref="VaultViewModel.ImportHostsAsync"/>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>A picker rather than the active vault, which is what the ssh_config importer still uses.</b>
|
|
/// The two are not the same question. That screen reads <c>~/.ssh/config</c> — this machine's own
|
|
/// account, on this machine — and "wherever I am standing" is a fair answer. This one reads a whole
|
|
/// project out of a company's console, and a project is exactly the thing a team shares: forty servers
|
|
/// filed into somebody's personal vault by default is either forty items nobody else can see, or forty
|
|
/// items moved one at a time afterwards.
|
|
/// </para>
|
|
/// <para>
|
|
/// Its own selection rather than <see cref="VaultViewModel.TargetVaults"/>'s, on the reasoning
|
|
/// <c>VaultViewModel.EditorVaultChoices</c> gives for the host editor's: that one is a standing
|
|
/// preference about where new items go, and this is a field of the import in front of you. Filled from
|
|
/// the same list, so it offers what every other "file this into" control offers — vaults this session
|
|
/// can both read and write.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal ObservableCollection<VaultChoiceViewModel> VaultChoices { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private VaultChoiceViewModel? selectedVault;
|
|
|
|
/// <summary>Whether there is more than one vault to choose between.</summary>
|
|
/// <remarks>
|
|
/// Hidden entirely at one rather than shown disabled, as the keychain screen's own picker is: a control
|
|
/// offering one option is a question with no answer.
|
|
/// </remarks>
|
|
internal bool HasVaultChoice => VaultChoices.Count > 1;
|
|
|
|
/// <summary>The vault the import writes to, or null for the active one.</summary>
|
|
internal Guid? TargetVaultId => SelectedVault?.VaultId;
|
|
|
|
/// <summary>What the summary line calls the vault the hosts are going into.</summary>
|
|
internal string VaultName => SelectedVault?.Name ?? vault.VaultName;
|
|
|
|
/// <summary>
|
|
/// The keys every imported host could be bound to: "no key", then everything in the keychain.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>The one thing this import could never do, done by pointing rather than by bringing.</b> Hetzner
|
|
/// has never held a private half, so no key can arrive <em>with</em> these hosts — but a key that is
|
|
/// already in the keychain is almost always the key that opens them, because it is the one whose public
|
|
/// half was handed to Hetzner when the servers were created. Binding it here is one answer for the whole
|
|
/// estate; without it, it was forty visits to forty host editors.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every readable vault's keys, not the target vault's. A binding resolves across vaults — that is what
|
|
/// makes one key on twenty hosts in three vaults the arrangement keys exist for — so narrowing this to
|
|
/// the destination would refuse a working answer. What it costs is a host whose colleagues cannot
|
|
/// resolve its key, and that is said in <see cref="AuthenticationNote"/> rather than prevented here.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal ObservableCollection<HetznerKeyChoice> KeyChoices { get; } = [];
|
|
|
|
[ObservableProperty]
|
|
private HetznerKeyChoice? selectedKey = HetznerKeyChoice.None;
|
|
|
|
/// <summary>Whether the keychain has a key to offer at all.</summary>
|
|
/// <remarks>
|
|
/// The picker goes entirely on a keychain with no keys in it, rather than standing there offering only
|
|
/// "no key" — which is a question with one answer, and an invitation to go looking for a control that
|
|
/// would do something.
|
|
/// </remarks>
|
|
internal bool HasKeyChoice => KeyChoices.Count > 1;
|
|
|
|
/// <summary>The key every imported host is bound to, where one was chosen.</summary>
|
|
internal Guid? BoundKeyId => SelectedKey?.KeyId;
|
|
|
|
/// <summary>How many ticked, out of how many, and where they land.</summary>
|
|
internal string SelectionSummary
|
|
{
|
|
get
|
|
{
|
|
var noun = Rows.Count == 1 ? "server" : "servers";
|
|
|
|
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// The sentence that says how an imported host will authenticate.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Said before the button rather than after, on exactly the reasoning <c>ImportScreen.axaml</c> gives
|
|
/// for its own version: whether the credential comes with the host is the difference between a bookmark
|
|
/// that connects and one that asks for a password, and somebody who is not told will conclude the
|
|
/// import was broken.
|
|
/// </para>
|
|
/// <para>
|
|
/// Two sentences now rather than one, because the answer is a choice. The first half of the original —
|
|
/// that Hetzner cannot hand over a private half — is true either way and stays in both, since it is the
|
|
/// fact that explains why the key had to be picked from a list instead of arriving with the servers.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string AuthenticationNote => SelectedKey?.KeyId is null
|
|
? "Hetzner has never held the private half of an SSH key, so nothing here can bring one. With no key "
|
|
+ "chosen above, a server imported this way arrives asking for a password until you bind it to "
|
|
+ "one — from the host's own editor, or in bulk from the Hosts screen."
|
|
: $"Hetzner has never held the private half of an SSH key, so nothing here brings one: every server "
|
|
+ $"imported is pointed at '{SelectedKey.Label}', which was already in your keychain. The key "
|
|
+ $"itself is not copied and not read.{StrandedKeyNote}";
|
|
|
|
/// <summary>
|
|
/// What is said when the chosen key sits in a different vault from the hosts.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same fact the move panel names when a host leaves its key behind, and it matters more here
|
|
/// because it is being done to a whole estate at once: a binding resolves across every vault
|
|
/// <em>this</em> account can read, so the import connects perfectly from this machine and not at all
|
|
/// for a colleague who holds only the destination's key. Stated rather than prevented — one key used by
|
|
/// hosts in three vaults is what keys are for.
|
|
/// </remarks>
|
|
private string StrandedKeyNote =>
|
|
SelectedKey?.VaultId is { } keyVault && TargetVaultId is { } target && keyVault != target
|
|
? $" That key lives in another vault than {VaultName}. It resolves for you, so these hosts will "
|
|
+ $"connect here — but anyone who can read {VaultName} and not the key's own vault will "
|
|
+ "see hosts they cannot authenticate."
|
|
: string.Empty;
|
|
|
|
/// <summary>What the token step says about the token it is asking for.</summary>
|
|
/// <remarks>
|
|
/// Three facts, each of which somebody would otherwise have to learn the hard way: a token is per
|
|
/// project rather than per account, read-only is enough, and nothing here keeps it. The first is the one
|
|
/// that surprises people — an account with four projects runs this four times, and a wizard that did not
|
|
/// say so would look like it had lost most of the estate.
|
|
/// </remarks>
|
|
internal static string TokenNote =>
|
|
"A Cloud API token belongs to one project, so this imports one project's servers — an account with "
|
|
+ "several runs this once per project. Make one in the Hetzner console under Security → API "
|
|
+ "tokens; Read is the only permission this needs, and the token is used for a single request "
|
|
+ "and then forgotten. Dedicated servers live behind Hetzner's Robot instead and are not "
|
|
+ "covered here.";
|
|
|
|
/// <summary>Reads the project. Writes nothing, and forgets the token on the way out.</summary>
|
|
[RelayCommand]
|
|
private async Task ScanAsync(CancellationToken cancellationToken)
|
|
{
|
|
ClearRows();
|
|
RefreshChoices();
|
|
|
|
IsBusy = true;
|
|
|
|
try
|
|
{
|
|
Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
|
|
|
|
if (Rows.Count == 0)
|
|
{
|
|
// Deliberately not an error, and deliberately not a step forward: an empty project is a
|
|
// real answer to a correct token, and a list screen with nothing on it says less than this.
|
|
Status = "That project has no servers in it.";
|
|
return;
|
|
}
|
|
|
|
Status = Rows.Count == 1
|
|
? "Found 1 server. Nothing is stored until you press the button below."
|
|
: $"Found {Rows.Count} servers. Nothing is stored until you press the button below.";
|
|
|
|
Step = HetznerImportStep.Choose;
|
|
}
|
|
catch (HetznerCloudException failure)
|
|
{
|
|
// Stays on the token step, which is where the box is. See HetznerCloudException.IsTokenProblem
|
|
// for why a fault at Hetzner's end is worded differently from a refused token even though both
|
|
// land here: one of them is something the person reading this can act on.
|
|
Status = failure.Message;
|
|
}
|
|
finally
|
|
{
|
|
// Cleared whatever happened, including on the way to a list somebody is about to import from:
|
|
// the read is over and the token has no further use. A failed attempt clears it too, so a
|
|
// mistyped token is retyped rather than corrected in place — the lesser of the two annoyances
|
|
// against a credential sitting in a text box for the rest of the session.
|
|
Token = string.Empty;
|
|
|
|
IsBusy = false;
|
|
RaiseListState();
|
|
}
|
|
}
|
|
|
|
/// <summary>Stores the ticked servers as hosts.</summary>
|
|
[RelayCommand]
|
|
private async Task ImportAsync(CancellationToken cancellationToken)
|
|
{
|
|
if (Port is not { } port)
|
|
{
|
|
Status = "That is not a port. Give a number between 1 and 65535.";
|
|
return;
|
|
}
|
|
|
|
var chosen = Rows.Where(row => row.IsSelected && row.CanImport).ToList();
|
|
|
|
if (chosen.Count == 0)
|
|
{
|
|
Status = "Nothing is ticked.";
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
|
|
try
|
|
{
|
|
// Key and KeyPath stay null and the binding goes on the host itself: those two are for key
|
|
// material arriving with an import, and there is none — see HetznerKeyChoice. A null BoundKeyId
|
|
// leaves the host bound to nothing, which is what it always was.
|
|
var requests = chosen
|
|
.Select(row => new ImportedHostRequest(
|
|
row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
|
|
Key: null,
|
|
KeyPath: null))
|
|
.ToList();
|
|
|
|
var outcome = await vault.ImportHostsAsync(requests, TargetVaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
// Re-resolved rather than cleared, so a row that was just imported now says "already here" —
|
|
// which is what makes coming back to the list and importing the rest of the project harmless
|
|
// and visible rather than harmless and confusing.
|
|
Refresh(resetSelection: true);
|
|
|
|
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
|
|
|
|
Step = HetznerImportStep.Done;
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
RaiseListState();
|
|
}
|
|
}
|
|
|
|
/// <summary>Back to the token step, with the list dropped.</summary>
|
|
/// <remarks>
|
|
/// The list goes because the token did. Nothing here can refresh what it is showing without asking for a
|
|
/// credential again, and a "back" that left forty rows on screen over a step that no longer has the
|
|
/// means to have produced them would be showing a list it cannot stand behind.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void Restart()
|
|
{
|
|
ClearRows();
|
|
Status = string.Empty;
|
|
Step = HetznerImportStep.Token;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Back from the last step to the list, with what was just stored marked as already here.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Unlike <see cref="Restart"/> this keeps the rows, and it can: they were read before the token was
|
|
/// cleared and nothing about them has to be fetched again. What it does not do is claim they are fresh —
|
|
/// a server created in the Hetzner console since the scan is not in this list and no screen here says
|
|
/// otherwise.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void Review()
|
|
{
|
|
if (Rows.Count > 0)
|
|
{
|
|
Step = HetznerImportStep.Choose;
|
|
}
|
|
}
|
|
|
|
/// <summary>Points the whole import at one of the three kinds of address.</summary>
|
|
[RelayCommand]
|
|
private void UseAddress(HetznerAddressKind kind) => AddressKind = kind;
|
|
|
|
/// <summary>Ticks or unticks every importable row at once.</summary>
|
|
[RelayCommand]
|
|
private void ToggleAll()
|
|
{
|
|
var importable = Rows.Where(row => row.CanImport).ToList();
|
|
var target = SelectedCount < importable.Count;
|
|
|
|
foreach (var row in importable)
|
|
{
|
|
row.IsSelected = target;
|
|
}
|
|
|
|
RaiseListState();
|
|
}
|
|
|
|
/// <summary>The footer's Cancel button: back to the Preferences page, nothing stored.</summary>
|
|
/// <remarks>
|
|
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the reasoning
|
|
/// <see cref="ImportViewModel"/>'s own cancel gives: this type has no business knowing settings mode
|
|
/// exists, and a null delegate — nothing wired, as in a test building this directly — makes the button a
|
|
/// no-op rather than a crash.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void Cancel() => onCancel?.Invoke();
|
|
|
|
/// <summary>What the import did, in the sentence the screen keeps after it.</summary>
|
|
/// <remarks>
|
|
/// This screen's own sentence rather than the vault's, which the push that follows an import overwrites
|
|
/// with a sync report — right on a screen somebody is about to leave, wrong on the one they are still
|
|
/// standing on. It names the vault and the key because those are the two answers that were chosen above
|
|
/// rather than assumed: which vault is what somebody will look for on the Hosts screen, and whether
|
|
/// there is a key is what they would otherwise find out at the next connection.
|
|
/// </remarks>
|
|
private string WhatWasStored(int hosts) => (hosts == 1, SelectedKey?.KeyId is not null) switch
|
|
{
|
|
(true, true) =>
|
|
$"Imported 1 server into {VaultName}. It is on the Hosts screen, bound to '{SelectedKey!.Label}'.",
|
|
(true, false) =>
|
|
$"Imported 1 server into {VaultName}. It is on the Hosts screen, asking for a password until "
|
|
+ "you bind it to a key.",
|
|
(false, true) =>
|
|
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, bound to "
|
|
+ $"'{SelectedKey!.Label}'.",
|
|
_ =>
|
|
$"Imported {hosts} servers into {VaultName}. They are on the Hosts screen, asking for a "
|
|
+ "password until you bind them to a key.",
|
|
};
|
|
|
|
/// <summary>Fills the list from what the project said, and resolves every row against the choices.</summary>
|
|
/// <remarks>
|
|
/// Its own method rather than part of <see cref="ScanAsync"/>, which is a request, a failure path and a
|
|
/// credential to forget already. Nothing here is stored: these rows are candidates until somebody ticks
|
|
/// them and presses the button.
|
|
/// </remarks>
|
|
private void Fill(HetznerImport import)
|
|
{
|
|
foreach (var server in import.Servers)
|
|
{
|
|
var row = new HetznerRowViewModel(server);
|
|
|
|
// The footer counts ticks, so it has to hear about one. Subscribed here rather than bound from
|
|
// the item template, because a template binding cannot reach the list it is inside without a
|
|
// parent binding — and the count is on the parent by definition.
|
|
row.PropertyChanged += OnRowChanged;
|
|
|
|
Rows.Add(row);
|
|
}
|
|
|
|
foreach (var warning in import.Warnings)
|
|
{
|
|
Warnings.Add(warning);
|
|
}
|
|
|
|
Refresh(resetSelection: true);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refills the vault and key pickers from the keychain, keeping whatever was already chosen.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Run at the start of every scan rather than once in a constructor, because the wizard outlives a
|
|
/// sync: a vault shared with this account, or a key a colleague pushed, between opening this screen and
|
|
/// pressing SCAN is a real answer that a list built at construction time would not be offering.
|
|
/// </para>
|
|
/// <para>
|
|
/// Selections are carried across <em>by id</em>, which is the same rule the host list's own reload
|
|
/// follows: the row objects are all new, so keeping the old ones would leave a picker showing a vault
|
|
/// the import would not actually write to. Anything that has since gone falls back to the default —
|
|
/// the active vault, and no key.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void RefreshChoices()
|
|
{
|
|
var vaultId = SelectedVault?.VaultId ?? vault.TargetVaultId;
|
|
var keyId = SelectedKey?.KeyId;
|
|
|
|
VaultChoices.Clear();
|
|
|
|
foreach (var choice in vault.TargetVaults)
|
|
{
|
|
VaultChoices.Add(choice);
|
|
}
|
|
|
|
SelectedVault = VaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId)
|
|
?? VaultChoices.FirstOrDefault();
|
|
|
|
KeyChoices.Clear();
|
|
KeyChoices.Add(HetznerKeyChoice.None);
|
|
|
|
foreach (var key in vault.Keys)
|
|
{
|
|
// The vault name where there is more than one to be in, exactly as the key list itself prints
|
|
// it: two vaults may each hold a "deploy", and a picker that binds forty machines to one of them
|
|
// has to say which. HasVaultBadge is that same test, already answered.
|
|
KeyChoices.Add(new HetznerKeyChoice(
|
|
key.EntityId,
|
|
key.VaultId,
|
|
key.HasVaultBadge ? $"{key.Label} · {key.VaultName}" : key.Label));
|
|
}
|
|
|
|
SelectedKey = KeyChoices.FirstOrDefault(choice => choice.KeyId == keyId) ?? HetznerKeyChoice.None;
|
|
|
|
OnPropertyChanged(nameof(HasVaultChoice));
|
|
OnPropertyChanged(nameof(HasKeyChoice));
|
|
}
|
|
|
|
/// <summary>Drops the list, unsubscribing from what it held.</summary>
|
|
/// <remarks>
|
|
/// The detach matters: rows outlive the list only through this handler, and a scan run four times would
|
|
/// otherwise leave three sets of dead rows still calling into a live view model on every tick.
|
|
/// </remarks>
|
|
private void ClearRows()
|
|
{
|
|
foreach (var row in Rows)
|
|
{
|
|
row.PropertyChanged -= OnRowChanged;
|
|
}
|
|
|
|
Rows.Clear();
|
|
Warnings.Clear();
|
|
RaiseListState();
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Only the tick moves the footer. Every other property on a row changes as part of a refresh this type
|
|
/// started, which already ends in <see cref="RaiseListState"/>.
|
|
/// </remarks>
|
|
private void OnRowChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs args)
|
|
{
|
|
if (string.Equals(args.PropertyName, nameof(HetznerRowViewModel.IsSelected), StringComparison.Ordinal))
|
|
{
|
|
RaiseListState();
|
|
}
|
|
}
|
|
|
|
/// <summary>Re-resolves every row against the current address choice.</summary>
|
|
private void Refresh(bool resetSelection)
|
|
{
|
|
foreach (var row in Rows)
|
|
{
|
|
row.Refresh(AddressKind, IsAlreadyPresent, resetSelection);
|
|
}
|
|
|
|
RaiseListState();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Whether the keychain already holds a host that would dial where this one does.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Matched on address, port and user rather than on name, for the reason
|
|
/// <see cref="ImportViewModel.IsAlreadyPresent"/> gives at more length: a machine bookmarked under
|
|
/// another name is the same machine, and matching on the label would offer a duplicate of something
|
|
/// already stored. Compared against the <em>resolved</em> host so that a stored host inheriting its port
|
|
/// or user from a group still matches.
|
|
/// </remarks>
|
|
private bool IsAlreadyPresent(HetznerAddress address) => vault.Hosts.Any(existing =>
|
|
string.Equals(existing.Host.Hostname, address.Value, StringComparison.OrdinalIgnoreCase)
|
|
&& existing.Resolved.Port.Value == Port
|
|
&& string.Equals(
|
|
existing.Resolved.Username.Value,
|
|
string.IsNullOrWhiteSpace(Username) ? null : Username.Trim(),
|
|
StringComparison.OrdinalIgnoreCase));
|
|
|
|
partial void OnStepChanged(HetznerImportStep value)
|
|
{
|
|
OnPropertyChanged(nameof(IsTokenStep));
|
|
OnPropertyChanged(nameof(IsChooseStep));
|
|
OnPropertyChanged(nameof(IsDoneStep));
|
|
OnPropertyChanged(nameof(StepLabel));
|
|
OnPropertyChanged(nameof(StepTitle));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The vault is named in three places on this step — the summary line, the note under it and the
|
|
/// sentence about a key left in another vault — and none of them is a control the picker owns.
|
|
/// </remarks>
|
|
partial void OnSelectedVaultChanged(VaultChoiceViewModel? value)
|
|
{
|
|
OnPropertyChanged(nameof(VaultName));
|
|
OnPropertyChanged(nameof(SelectionSummary));
|
|
OnPropertyChanged(nameof(AuthenticationNote));
|
|
}
|
|
|
|
/// <inheritdoc cref="OnSelectedVaultChanged" />
|
|
partial void OnSelectedKeyChanged(HetznerKeyChoice? value) =>
|
|
OnPropertyChanged(nameof(AuthenticationNote));
|
|
|
|
partial void OnAddressKindChanged(HetznerAddressKind value)
|
|
{
|
|
OnPropertyChanged(nameof(UsesPublicIPv4));
|
|
OnPropertyChanged(nameof(UsesPublicIPv6));
|
|
OnPropertyChanged(nameof(UsesPrivate));
|
|
OnPropertyChanged(nameof(AddressNote));
|
|
|
|
Refresh(resetSelection: false);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// The user and the port are half of what "already here" is decided on, so changing either has to
|
|
/// re-ask the question — otherwise a row would go on claiming the keychain has it while the thing it
|
|
/// would now store is different.
|
|
/// </remarks>
|
|
partial void OnUsernameChanged(string value) => Refresh(resetSelection: false);
|
|
|
|
/// <inheritdoc cref="OnUsernameChanged" />
|
|
partial void OnPortTextChanged(string value)
|
|
{
|
|
OnPropertyChanged(nameof(IsPortValid));
|
|
|
|
Refresh(resetSelection: false);
|
|
}
|
|
|
|
partial void OnIsBusyChanged(bool value) => OnPropertyChanged(nameof(CanImport));
|
|
|
|
private void RaiseListState()
|
|
{
|
|
OnPropertyChanged(nameof(HasRows));
|
|
OnPropertyChanged(nameof(HasWarnings));
|
|
OnPropertyChanged(nameof(SelectedCount));
|
|
OnPropertyChanged(nameof(ImportableCount));
|
|
OnPropertyChanged(nameof(ImportLabel));
|
|
OnPropertyChanged(nameof(AllTicked));
|
|
OnPropertyChanged(nameof(CanImport));
|
|
OnPropertyChanged(nameof(SelectionSummary));
|
|
}
|
|
}
|