Public Access
Step 4 of docs/adding-hosts-on-the-phone.md. The domain could resolve a host against its groups; nothing asked it to. This is the wiring, and it is mostly one change repeated: read the resolved host, not the stored one. TryBuildAuthentication and TryBuildConnectionRequest now take the resolved value beside the stored one, which is where group context was being lost. It is the only authentication resolution in the product — both heads and both transports come through it — so a host inheriting its binding would otherwise have been offered a password prompt on every screen at once. The credential-username fallback becomes three levels, and Complete still refuses an empty username, but now only after the chain has been walked; refusing before it would refuse exactly the hosts inheritance exists to serve. HostRowViewModel carries its ResolvedHost, resolved once when the list is built. Address, Authentication and Dialled read it, so a row cannot disagree with itself about what it dials — and MainWindowViewModel.Rank searches Address, so a host inheriting 2222 that displayed 22 would have been unfindable by the port it actually answers on. HostsBoundTo counts over the resolved binding, which is the difference between a warning and a silence: a key bound once on a group and inherited by twenty hosts named nobody, would have been deleted, and would then have refused all twenty at connect time. HostFields.From is answered by a refusal rather than by threading a group list through the sync engine. A relay host may not inherit its port. The reason is stronger than the convenience: a plaintext column is a derived duplicate the client supplies when it pushes *this* host, so an inherited port would make it depend on another item — editing a group would change what the relay dials for every host beneath it, except that nothing re-pushes those hosts, so the server would keep dialling the old port until each was next touched for some unrelated reason. A stale wire on the relay path connects the user to the wrong service. The editor distinguishes unset from explicit in both directions. An empty port box means "take the group's" and shows what that will be as a placeholder, following the group picker as it moves — a pre-filled 2222 would have been indistinguishable from one the user typed, and saving would have pinned it. The authentication picker gains a fourth entry, offered only to a host in a group, because for an ungrouped host it would behave exactly like the first. Which found a real defect while the tests were being written. Filing an ungrouped host into a group silently pinned it to a typed password: the picker had no "Inherit" entry when it opened, so it sat on "Password (ask each time)", and saving wrote that as a decision — the host would have been pinned to a prompt nobody asked for and the group's key would never have reached it. Two guards now: the picker is rebuilt when the group changes, and BuildHost writes AsksForPassword only for a host that had the alternative on offer. The group editor is here too, and the plan never assigned it a step. Without it no group can carry a default, so every line above would have been unreachable. It grows a parent picker that leaves out the group itself and everything beneath it — a courtesy rather than the guarantee, since a cycle assembled from two offline re-parents was never offered that list — and three defaults beside the name, each of which may be left empty because "lend nothing" is an answer. Tags are stored and not editable. TagIds merges, encodes and resolves; no screen can set one yet, and the editor carries the set through a save untouched so a client that can set them does not lose them to somebody editing a port. Eight new tests, and they dial. That is the point of them: a resolved value that never reaches SshConnectionRequest is a label, and every one of these failures would be silent — a host connecting to the wrong port, or being asked for a password it does not need, with nothing on screen admitting it. Verified by the whole suite: 1390 tests over nineteen projects, none failing. Both heads build. Nothing on the phone has changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
250 lines
9.2 KiB
C#
250 lines
9.2 KiB
C#
using System.Collections.ObjectModel;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Import;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <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
|
|
/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
|
|
/// rather than an action.
|
|
/// </remarks>
|
|
internal sealed partial class ImportRowViewModel : ObservableObject
|
|
{
|
|
private readonly ImportedHost host;
|
|
|
|
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
|
|
{
|
|
this.host = host;
|
|
AlreadyPresent = alreadyPresent;
|
|
|
|
// 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
|
|
// default.
|
|
IsSelected = !alreadyPresent;
|
|
}
|
|
|
|
internal ImportedHost Host => host;
|
|
|
|
internal string Alias => host.Alias;
|
|
|
|
internal string Address => host.Address;
|
|
|
|
/// <summary>Whether a host with this address is already in the keychain.</summary>
|
|
internal bool AlreadyPresent { get; }
|
|
|
|
internal string Badge => AlreadyPresent ? "already here" : string.Empty;
|
|
|
|
internal bool HasBadge => AlreadyPresent;
|
|
|
|
/// <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.
|
|
/// </remarks>
|
|
internal string Authentication => host.IdentityFiles.Count switch
|
|
{
|
|
0 => "password",
|
|
1 => $"a key on disk · {host.IdentityFiles[0]}",
|
|
var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
|
|
};
|
|
|
|
internal bool HasWarnings => host.Warnings.Count > 0;
|
|
|
|
internal string Warnings => string.Join(" ", host.Warnings);
|
|
|
|
[ObservableProperty]
|
|
private bool isSelected;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reading <c>~/.ssh/config</c> and offering what it found.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Two steps, and the first one writes nothing.</b> Scanning reads the file and shows what it means;
|
|
/// importing is a separate press. That split is the feature: an <c>ssh_config</c> is a file this
|
|
/// application did not write and may contain forty entries for machines that no longer exist, so the
|
|
/// 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
|
|
{
|
|
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
|
|
|
|
/// <summary>What was skipped or flattened, at document level.</summary>
|
|
internal ObservableCollection<string> Warnings { get; } = [];
|
|
|
|
/// <summary>The file this would read, shown so nobody has to guess which one it means.</summary>
|
|
internal string ConfigPath => locator.ConfigPath;
|
|
|
|
[ObservableProperty]
|
|
private string status = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool hasScanned;
|
|
|
|
[ObservableProperty]
|
|
private bool isBusy;
|
|
|
|
internal bool HasRows => Rows.Count > 0;
|
|
|
|
internal bool HasWarnings => Warnings.Count > 0;
|
|
|
|
internal int SelectedCount => Rows.Count(row => row.IsSelected);
|
|
|
|
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
|
|
|
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
|
|
[RelayCommand]
|
|
private async Task ScanAsync(CancellationToken cancellationToken)
|
|
{
|
|
Rows.Clear();
|
|
Warnings.Clear();
|
|
HasScanned = false;
|
|
|
|
if (!locator.Exists)
|
|
{
|
|
Status = $"There is no {locator.ConfigPath} on this machine.";
|
|
RaiseListState();
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
|
|
try
|
|
{
|
|
var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
foreach (var host in import.Hosts)
|
|
{
|
|
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
|
|
}
|
|
|
|
foreach (var warning in import.Warnings)
|
|
{
|
|
Warnings.Add(warning);
|
|
}
|
|
|
|
HasScanned = true;
|
|
|
|
Status = Rows.Count == 0
|
|
? "Nothing in that file could be imported as a host."
|
|
: $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
|
|
}
|
|
catch (IOException failure)
|
|
{
|
|
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
|
}
|
|
catch (UnauthorizedAccessException failure)
|
|
{
|
|
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
RaiseListState();
|
|
}
|
|
}
|
|
|
|
/// <summary>Stores the ticked hosts.</summary>
|
|
[RelayCommand]
|
|
private async Task ImportAsync(CancellationToken cancellationToken)
|
|
{
|
|
var chosen = Rows.Where(row => row.IsSelected).ToList();
|
|
|
|
if (chosen.Count == 0)
|
|
{
|
|
Status = "Nothing is ticked.";
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
|
|
try
|
|
{
|
|
var imported = await vault
|
|
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], 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));
|
|
}
|
|
|
|
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
RaiseListState();
|
|
}
|
|
}
|
|
|
|
/// <summary>Ticks or unticks everything at once.</summary>
|
|
[RelayCommand]
|
|
private void ToggleAll()
|
|
{
|
|
var target = SelectedCount < Rows.Count;
|
|
|
|
foreach (var row in Rows)
|
|
{
|
|
row.IsSelected = target;
|
|
}
|
|
|
|
RaiseListState();
|
|
}
|
|
|
|
internal void NoteSelectionChanged() => RaiseListState();
|
|
|
|
/// <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
|
|
/// to import a duplicate of something already stored under another name.
|
|
/// </remarks>
|
|
/// <summary>
|
|
/// Whether this block describes a machine the vault already has.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Compared against the resolved host, not the stored one.</b> A stored host that takes its port and
|
|
/// username from its group is the same machine as an imported block naming them outright — and comparing
|
|
/// the stored fields would leave it unmatched, so the import screen would offer to add a duplicate of
|
|
/// every host that inherits anything. Duplicates offered by a screen whose whole job is to say what is
|
|
/// new are worse than a missed match: they get accepted.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>An imported block with no <c>Port</c> still pins 22 rather than inheriting</b>, which
|
|
/// <c>SshConfigResolver</c> already does and this deliberately leaves alone. Nothing imported is filed
|
|
/// into a group — there is no group picker here — so an inherited port would resolve to 22 anyway, and
|
|
/// the two would differ only in which of them a later edit to some group could change underneath the
|
|
/// user. An absent <c>Port</c> in an ssh_config means 22; storing that is the faithful reading.
|
|
/// </para>
|
|
/// </remarks>
|
|
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
|
|
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
|
|
&& existing.Resolved.Port.Value == host.Port
|
|
&& string.Equals(
|
|
existing.Resolved.Username.Value, host.Username, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private void RaiseListState()
|
|
{
|
|
OnPropertyChanged(nameof(HasRows));
|
|
OnPropertyChanged(nameof(HasWarnings));
|
|
OnPropertyChanged(nameof(SelectedCount));
|
|
OnPropertyChanged(nameof(ImportLabel));
|
|
}
|
|
}
|