Public Access
Give the hosts board its vaults, and the Hetzner import a vault and a key
Six things, all of them answers to a question a screen was deciding on somebody's behalf. The board draws a heading per vault and sorts each vault's hosts by that vault's own groups. A group is an item of one vault, so drawing every group of every vault in one run flattened away containment that already exists: two vaults each holding a "production" were two headings with the same word on them, told apart by a small grey name beside each. Single- vault sessions are unchanged. There is now one "No group" heading per vault, so the folded set is keyed on SidebarGroupHeader.CollapseKey rather than on GroupId ?? Guid.Empty, which had one vault's unfiled hosts folding every other vault's away with them. A Ctrl-click starts its set with the card the board has lit. The board always has a selection — a fresh unlock lights the first host so CONNECT has something under it — so "one card lit, nothing ticked" is the state this screen opens in, and ticking a second card there produced a set of one: the machine the user was looking at was left out of the count and out of everything Delete, Move and Change group then ran over. The card tooltip says the host's name as well as its address. 214 pixels of card trims the name, and the tooltip was the one place it was not said in full. The card's floor drops from 120 to 104. A card with no tags measures about 87, so the mock's number was thirty pixels of air under every card on a board that is scrolled. "Move to another vault…" joins the board's context menu for one host. Moving one machine meant opening its pane and finding the menu inside it, while moving six was a right click away — the one-host case was the harder of the two. And the Hetzner import asks which vault and which key. A project belongs to a team more often than to a person, so forty servers landing wherever the session happens to be standing is the wrong default; ImportHostsAsync takes the target vault, and the ssh_config importer passes null and is unchanged. The key is the half Hetzner cannot supply and the keychain usually already holds — the public half went to Hetzner when the servers were made — so the import points every host at it rather than bringing one. It defaults to no key rather than guessing, and says so when the key it was given lives in a different vault from the hosts.
This commit is contained in:
1 parent
ed936380b3
commit
c859705b83
9 files changed
+926
-84
No files matched your search
@@ -148,6 +148,38 @@ internal sealed partial class HetznerRowViewModel(HetznerServer server) : Observ
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -310,14 +342,76 @@ internal sealed partial class HetznerImportViewModel(
|
||||
internal bool CanImport => SelectedCount > 0 && IsPortValid && !IsBusy;
|
||||
|
||||
/// <summary>
|
||||
/// The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.
|
||||
/// Where the imported hosts go — see <see cref="VaultViewModel.ImportHostsAsync"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fixed rather than offered as a picker, exactly as the ssh_config importer's is: the import goes
|
||||
/// through the same active vault every other bulk write does, and there is no per-import target to bind.
|
||||
/// Printed as a fact instead of drawn as a dropdown.
|
||||
/// <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 string VaultName => vault.VaultName;
|
||||
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
|
||||
@@ -331,18 +425,45 @@ internal sealed partial class HetznerImportViewModel(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sentence that says an imported host will not connect on its own.
|
||||
/// 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 static string AuthenticationNote =>
|
||||
"Hetzner has never held the private half of an SSH key, so nothing here can bring one. A server "
|
||||
+ "imported this way arrives asking for a password until you bind it to a key already in your "
|
||||
+ "keychain — from the host's own editor, or in bulk from the Hosts screen.";
|
||||
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>
|
||||
@@ -363,31 +484,13 @@ internal sealed partial class HetznerImportViewModel(
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ClearRows();
|
||||
RefreshChoices();
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
var import = await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
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);
|
||||
Fill(await hetzner.ListServersAsync(Token, cancellationToken).ConfigureAwait(true));
|
||||
|
||||
if (Rows.Count == 0)
|
||||
{
|
||||
@@ -445,28 +548,25 @@ internal sealed partial class HetznerImportViewModel(
|
||||
|
||||
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),
|
||||
row.Server.ToSecret(row.Resolved!, Username, port) with { SshKeyId = BoundKeyId },
|
||||
Key: null,
|
||||
KeyPath: null))
|
||||
.ToList();
|
||||
|
||||
var outcome = await vault.ImportHostsAsync(requests, cancellationToken).ConfigureAwait(true);
|
||||
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);
|
||||
|
||||
// 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.
|
||||
Status = outcome.Hosts == 1
|
||||
? "Imported 1 server. It is on the Hosts screen, asking for a password until you bind it "
|
||||
+ "to a key."
|
||||
: $"Imported {outcome.Hosts} servers. They are on the Hosts screen, asking for a password "
|
||||
+ "until you bind them to a key.";
|
||||
Status = WhatWasStored(outcome.Hosts);
|
||||
|
||||
Step = HetznerImportStep.Done;
|
||||
}
|
||||
@@ -538,6 +638,108 @@ internal sealed partial class HetznerImportViewModel(
|
||||
[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
|
||||
@@ -605,6 +807,21 @@ internal sealed partial class HetznerImportViewModel(
|
||||
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));
|
||||
|
||||
Reference in new issue
Block a user