using System.Collections.ObjectModel; using System.Globalization; using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Api; using DodoSSH.Client.Domain; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Sync; using DodoSSH.Client.Terminal; namespace DodoSSH.Client.App.ViewModels; /// One host, as a row in the list. /// /// Carries the decrypted so opening the editor needs no second decryption, and /// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and /// an item a newer client wrote that must not be re-encoded here. /// internal sealed class HostRowViewModel(VaultItem host) { internal Guid EntityId => host.EntityId; internal HostSecret Host => host.Secret; internal string Label => host.Secret.Label; internal string Address => string.Create( CultureInfo.InvariantCulture, $"{host.Secret.Username ?? "—"}@{host.Secret.Hostname}:{host.Secret.Port}"); internal bool HasUnsyncedChanges => host.HasUnsyncedChanges; internal bool IsBlocked => host.IsBlocked; internal bool IsReadOnly => host.IsReadOnly; /// How this host authenticates, in one word. /// /// Worth a word in the list because the two behave differently at the moment of connecting: one needs /// the password box filled in and the other does not, and a user staring at an empty password box on a /// key-authenticated host has no other way to know it is not needed. /// internal string Authentication => host.Secret.SshKeyId is null ? "password" : "key"; /// A short marker for the row, so the list says what it knows without a tooltip. internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges); } /// An entry in the host editor's key picker. /// The key's item id, or null for password authentication. /// What to show. /// /// A sentinel entry rather than a nullable selection, because a ComboBox with nothing selected and a /// ComboBox meaning "no key" look identical and are not the same thing — the first is a host whose binding /// has not been decided, the second is a decision. /// internal sealed record SshKeyChoice(Guid? EntityId, string Label) { /// The "use a password" entry, always first. internal static SshKeyChoice None { get; } = new(null, "Password (no key)"); /// /// A stand-in for a key the host names and the vault no longer has. /// /// /// Kept in the list, and kept selected, so that opening a host to change its port does not silently /// convert it to password authentication on save. The id is preserved; only the label admits the /// problem. /// internal static SshKeyChoice Missing(Guid entityId) => new(entityId, "(a key that is no longer here)"); } /// One SSH key, as a row in the list. /// /// /// Carries the decrypted , as the host row carries its host, so opening the /// editor or connecting with the key needs no second decryption. /// /// /// Nothing here exposes the private key to the view. is what the editor and the /// connect path read, and the members the XAML binds are the label, a description and a badge. That is not /// a security boundary — the same object holds the material either way — but it does mean no template, /// tooltip or accessibility surface can end up rendering a private key by being pointed at the obvious /// property. /// /// internal sealed class SshKeyRowViewModel(VaultItem key) { internal Guid EntityId => key.EntityId; internal SshKeySecret Key => key.Secret; internal string Label => key.Secret.Label; /// What the list shows under the name: what is known about the key, never the key. internal string Description => key.Secret switch { { Passphrase: not null, PublicKey: not null } => "passphrase · public half stored", { Passphrase: not null } => "passphrase · no public half", { PublicKey: not null } => "no passphrase · public half stored", _ => "no passphrase · no public half", }; internal bool HasUnsyncedChanges => key.HasUnsyncedChanges; internal bool IsBlocked => key.IsBlocked; internal bool IsReadOnly => key.IsReadOnly; internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges); } /// The one-word marker a row shows for its sync state. /// /// Shared by both row types rather than written twice, because the three states mean the same thing for /// every item type and a list where one kind said "not synced" and the other "unsynced" would read as two /// different conditions. /// internal static class ItemBadge { internal static string For(bool isBlocked, bool isReadOnly, bool hasUnsyncedChanges) => (isBlocked, isReadOnly, hasUnsyncedChanges) switch { (true, _, _) => "rejected", (_, true, _) => "newer version", (_, _, true) => "not synced", _ => string.Empty, }; } /// A conflict, as a row. internal sealed class ConflictRowViewModel(ConflictNotice notice) { internal Guid Id => notice.Id; internal string Summary => notice.Summary; /// /// The overridden values, one line each. /// /// /// This is the whole justification for resolving a conflict automatically. If these were not shown, /// the merge would be last-writer-wins with a longer explanation. /// // The lambda parameter is 'entry' rather than the obvious 'field': C# 14 made that a contextual // keyword inside a property accessor, and this whole expression is one. internal string Detail => notice.Fields.Count == 0 ? string.Empty : string.Join( Environment.NewLine, notice.Fields.Select(entry => entry.DiscardedWasRemoval ? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept" : $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'")); internal bool HasDetail => notice.Fields.Count > 0; } /// /// An open vault: the host list, the editor, syncing, and connecting a terminal. /// /// /// /// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a /// delete disappears immediately whether or not the network is there. Nothing here waits on a server to /// show a change. /// /// /// Syncing then happens on its own: once when the vault opens, straight after any local change, and on a /// timer while it stays open. The Sync button remains, because a person who has just been handed a /// credential wants to know now rather than within the minute — but nothing depends on it being pressed. /// A background pass is deliberately quieter than the button: see . /// /// /// Keys and host key trust are in the vault; passwords are not yet. An SSH key is a synced item, so /// it is stored once and available on every machine, and so is a known host key — approving a fingerprint /// here approves it on every device and survives a restart. Credentials are synced as well, but nothing in /// this interface can create one, so password authentication still asks each time. That is a real M1 /// limitation rather than a design choice, and the interface says so rather than implying the vault holds /// more than it does. /// /// /// A key belongs to a host. Each host names the key it authenticates with, or none, and that choice /// is a field in its encrypted payload — so it follows the host to every machine rather than being made /// again per connection. The cost is a payload schema version, paid only by hosts that actually bind a key: /// see HostSecretCodec.CurrentSchemaVersion. /// /// internal sealed partial class VaultViewModel( VaultSession session, TerminalWorkspace workspace, VaultKnownHostStore knownHosts, Func connection) : ObservableObject, IAsyncDisposable { /// /// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the /// server almost nothing; the number that matters is how stale a teammate's change may look, and a /// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake, /// and a change made on this machine does not wait for the timer anyway — saving pushes immediately. /// private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1); /// Serialises every synchronisation pass, whether a button pressed it or a timer did. private readonly SemaphoreSlim syncGate = new(1, 1); private CancellationTokenSource? autoSync; private Task? autoSyncLoop; private bool disposed; /// The hosts to show, unpushed local state included. internal ObservableCollection Hosts { get; } = []; /// The SSH keys to show, unpushed local state included. internal ObservableCollection Keys { get; } = []; /// Whatever the merge had to override and the user has not acknowledged. internal ObservableCollection Conflicts { get; } = []; internal string VaultName => session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Vault"; [ObservableProperty] private HostRowViewModel? selectedHost; [ObservableProperty] private SshKeyRowViewModel? selectedKey; [ObservableProperty] private string status = string.Empty; [ObservableProperty] private int pendingChanges; [ObservableProperty] private int unreadableItems; [ObservableProperty] private bool isBusy; // ---- The editor ---- [ObservableProperty] private bool isEditing; [ObservableProperty] private string editorLabel = string.Empty; [ObservableProperty] private string editorHostname = string.Empty; [ObservableProperty] private int editorPort = HostSecret.DefaultPort; [ObservableProperty] private string editorUsername = string.Empty; [ObservableProperty] private string editorNotes = string.Empty; [ObservableProperty] private bool editorRelayEnabled; /// /// What the key picker offers: password, then every key in the vault. /// /// /// Rebuilt when the editor opens rather than kept in step with the key list. A background sync could /// pull a new key while a host is being edited, and having the picker's contents change under the user /// mid-edit is worse than the list being a minute stale — the two editors cannot be open at once, so /// the only way to add a key is to close this one anyway. /// internal ObservableCollection EditorKeyChoices { get; } = []; [ObservableProperty] private SshKeyChoice? editorSelectedKey; /// The item being edited, or null when creating. private Guid? editingEntityId; /// /// Whether the editor is showing a host that could have a pinned key to forget. /// /// /// Read by the editor to hide the button while a host is being created, where there is nothing to /// withdraw yet. It does not claim a pin exists — answering that would mean a second question to the /// known-host store for a button's visibility, and the command already says plainly when there was /// nothing to forget. /// internal bool CanForgetHostKey => IsEditing && editingEntityId is not null; // ---- The key editor ---- // A second set of editor state rather than a shared one. The two editors hold unrelated fields, and // sharing them would mean a half-typed host reappearing inside a key editor. [ObservableProperty] private bool isEditingKey; [ObservableProperty] private string keyEditorLabel = string.Empty; /// /// Bound to a text box the user pastes a private key into, so this holds key material for as long as /// the editor is open, and clears it. Neither that nor anything else here /// can wipe it — see SshKeySecret, which explains why a .NET string is the honest choice for /// this and what it does not buy. /// [ObservableProperty] private string keyEditorPrivateKey = string.Empty; [ObservableProperty] private string keyEditorPassphrase = string.Empty; [ObservableProperty] private string keyEditorPublicKey = string.Empty; [ObservableProperty] private string keyEditorNotes = string.Empty; /// The key being edited, or null when creating. private Guid? editingKeyId; // ---- Connecting ---- /// /// Typed per connection because nothing in this interface can create a vault credential yet — not because /// the vault cannot hold one. Never persisted. /// [ObservableProperty] private string connectPassword = string.Empty; /// Whether the selected host authenticates with a key, so the password box can say so. internal bool SelectedHostUsesAKey => SelectedHost?.Host.SshKeyId is not null; [ObservableProperty] private HostKeyPresentation? pendingHostKey; [ObservableProperty] private string? hostKeyMismatch; /// /// Raised once a terminal session is open and its renderer has it. /// /// /// An event rather than a property because handing the terminal the keyboard is something that /// happens, not something that is true: connecting a second host while one is already open has to /// move focus again, and no state change describes that. Raised on the UI thread — every await on /// the path from the command to here uses ConfigureAwait(true) — so a handler may touch /// controls directly. /// internal event EventHandler? SessionOpened; internal bool HasPendingHostKey => PendingHostKey is not null; internal bool HasHostKeyMismatch => HostKeyMismatch is not null; internal bool HasConflicts => Conflicts.Count > 0; /// Reads the vault into the list and says what is in it. internal async Task LoadAsync(CancellationToken cancellationToken) { await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = (Hosts.Count, Keys.Count) switch { (0, 0) => "No hosts yet. Add one.", (0, var keys) => $"No hosts yet, and {keys} key(s) in {VaultName}.", (var hosts, 0) => $"{hosts} host(s) in {VaultName}.", var (hosts, keys) => $"{hosts} host(s) and {keys} key(s) in {VaultName}.", }; } /// /// Reads the vault into the list, silently. /// /// /// Separate from because every caller except the first load has something /// better to say afterwards — a save, a deletion, or a sync report — and a background pass has nothing /// to say at all. Rebuilding the list used to repaint the status line unconditionally, which made /// "the background pass is quiet" false on the one path that mattered. /// private async Task ReloadAsync(CancellationToken cancellationToken) { var unreadable = await ReloadHostsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true); UnreadableItems = unreadable; PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true); await LoadConflictsAsync(cancellationToken).ConfigureAwait(true); } /// How many hosts would not decrypt. private async Task ReloadHostsAsync(CancellationToken cancellationToken) { var listing = await session.Hosts .ListAsync(session.ActiveVaultId, cancellationToken) .ConfigureAwait(true); var selectedId = SelectedHost?.EntityId; Hosts.Clear(); foreach (var host in listing.Items.OrderBy(host => host.Secret.Label, StringComparer.CurrentCulture)) { Hosts.Add(new HostRowViewModel(host)); } // Selection survives a reload. Losing it on every sync would move the terminal's target out from // under the user. SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault(); return listing.Unreadable; } /// How many keys would not decrypt. /// /// Unlike the host list, the selection is not defaulted to the first row. A key selection is /// what authenticates with, and quietly selecting one on load would /// mean a connection made with a key the user never chose. /// private async Task ReloadKeysAsync(CancellationToken cancellationToken) { var listing = await session.SshKeys .ListAsync(session.ActiveVaultId, cancellationToken) .ConfigureAwait(true); var selectedId = SelectedKey?.EntityId; Keys.Clear(); foreach (var key in listing.Items.OrderBy(key => key.Secret.Label, StringComparer.CurrentCulture)) { Keys.Add(new SshKeyRowViewModel(key)); } SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId); return listing.Unreadable; } /// Runs a synchronisation pass, if there is a server to talk to. [RelayCommand] private async Task SyncAsync(CancellationToken cancellationToken) { if (connection() is not { } server) { Status = "Offline. Changes are queued and will be sent after you sign in."; return; } await RunAsync( "Synchronising…", async () => { var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true); // Null means a background pass held the gate. Saying so beats reporting a sync that this // press did not perform. Status = report is null ? "A synchronisation is already running." : Describe(report); }).ConfigureAwait(true); } /// /// Starts syncing in the background until the vault is disposed. /// /// /// Explicit rather than started from the constructor, so that a test can drive /// a pass at a time instead of racing a timer. /// internal void StartAutoSync() { if (autoSync is not null) { return; } autoSync = new CancellationTokenSource(); autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token); } /// /// One background synchronisation pass, which stays out of the way. /// /// /// /// Deliberately not routed through . That would raise the busy flag every /// interval — disabling Connect and Save for the duration — and repaint the status line with /// "Synchronising…" while the user was reading something else. A background pass that makes the /// application feel intermittently broken is worse than a Sync button. /// /// /// So it is silent unless it has something to say: the status line changes only when the pass actually /// moved an item or produced something needing attention. It also yields to the user — a pass is /// skipped outright while a command is running, rather than queueing behind it. /// /// internal async Task AutoSyncAsync(CancellationToken cancellationToken) { if (IsBusy || connection() is not { } server) { return; } try { var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true); if (report is not null && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention)) { Status = Describe(report); } } catch (OperationCanceledException) { // Locking, or closing. } catch (Exception exception) when (exception is not OutOfMemoryException) { // Swallowed on purpose, and this is the one place in the view model where that is right: a // laptop that has been closed all afternoon would otherwise replace whatever the user was // reading with a socket error once a minute. The failure is not hidden — the account bar // already shows when there is no connection, and pressing Sync reports the real reason. } } /// /// The gate is shared with the manual command, so a press and a tick can never overlap. Taken with a /// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by /// waiting for it, and queueing them would turn a slow server into a backlog of identical work. /// private async Task SyncOnceAsync(ISyncApi api, CancellationToken cancellationToken) { if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true)) { return null; } try { var report = await session.SyncAsync(api, cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); // Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a // snapshot rather than reading per lookup — so a pass that pulled a pin has to hand it over here, // or a host a colleague approved stays a first-contact prompt until the next unlock. await knownHosts.RefreshAsync(cancellationToken).ConfigureAwait(true); return report; } finally { syncGate.Release(); } } /// /// ConfigureAwait(true) throughout, and that is load-bearing rather than habit: the loop is /// started from the UI thread, so every continuation returns to it and the observable collections /// rebuilds are still only ever touched from one thread. A /// ConfigureAwait(false) here would mutate them from a timer thread, which Avalonia will /// eventually notice in a way that looks like a rendering bug. /// private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken) { using var timer = new PeriodicTimer(AutoSyncInterval); try { // A pass on open, before the first tick. A vault edited on another machine should be current by // the time the user has finished reading the list, not a minute afterwards. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true)) { await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } } catch (OperationCanceledException) { // Locking, or closing. } } /// Starts a new host. [RelayCommand] private void NewHost() { if (KeyEditorIsInTheWay()) { return; } editingEntityId = null; EditorLabel = string.Empty; EditorHostname = string.Empty; EditorPort = HostSecret.DefaultPort; EditorUsername = string.Empty; EditorNotes = string.Empty; EditorRelayEnabled = false; BuildKeyChoices(boundKeyId: null); IsEditing = true; Status = "Adding a host."; } /// Opens the selected host for editing. [RelayCommand] private void EditSelectedHost() { if (SelectedHost is not { } row || KeyEditorIsInTheWay()) { return; } if (row.IsReadOnly) { // Re-encoding would drop fields this build has no concept of, so the honest answer is to // refuse rather than to silently lose a colleague's data. Status = "This host was written by a newer version of DodoSSH. Update before editing it."; return; } editingEntityId = row.EntityId; EditorLabel = row.Host.Label; EditorHostname = row.Host.Hostname; EditorPort = row.Host.Port; EditorUsername = row.Host.Username ?? string.Empty; EditorNotes = row.Host.Notes ?? string.Empty; EditorRelayEnabled = row.Host.RelayEnabled; BuildKeyChoices(row.Host.SshKeyId); IsEditing = true; Status = $"Editing {row.Label}."; } /// Abandons the editor. [RelayCommand] private void CancelEdit() { IsEditing = false; editingEntityId = null; Status = string.Empty; } /// Stores the editor's contents, encrypted, and queues it for the server. [RelayCommand] private async Task SaveHostAsync(CancellationToken cancellationToken) { var host = BuildHost(); if (!host.TryValidate(out var error)) { Status = error; return; } await RunAsync( "Saving…", async () => { if (editingEntityId is { } entityId) { await session.Hosts .UpdateAsync(session.ActiveVaultId, entityId, host, cancellationToken) .ConfigureAwait(true); } else { editingEntityId = await session.Hosts .CreateAsync(session.ActiveVaultId, host, cancellationToken) .ConfigureAwait(true); } IsEditing = false; await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId); editingEntityId = null; Status = connection() is null ? $"Saved '{host.Label}'. It will sync when you are online." : $"Saved '{host.Label}'."; }).ConfigureAwait(true); // Pushed now rather than at the next tick. A change the user just made is the one they are most // likely to be about to look for on another machine. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Queues a tombstone for the selected host. [RelayCommand] private async Task DeleteHostAsync(CancellationToken cancellationToken) { if (SelectedHost is not { } row) { return; } await RunAsync( "Deleting…", async () => { await session.Hosts .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted '{row.Label}'."; }).ConfigureAwait(true); // As with saving: a tombstone is worth pushing straight away, so the item does not reappear on // another machine that syncs before the next tick. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Starts a new SSH key. [RelayCommand] private void NewKey() { if (HostEditorIsInTheWay()) { return; } editingKeyId = null; ClearKeyEditor(); IsEditingKey = true; Status = "Adding an SSH key."; } /// Opens the selected key for editing. /// /// The private key is loaded into the editor, which is the only way an edit can preserve it: the /// codec has no notion of a partial update, so saving re-encodes every field. /// [RelayCommand] private void EditSelectedKey() { if (SelectedKey is not { } row || HostEditorIsInTheWay()) { return; } if (row.IsReadOnly) { Status = "This key was written by a newer version of DodoSSH. Update before editing it."; return; } editingKeyId = row.EntityId; KeyEditorLabel = row.Key.Label; KeyEditorPrivateKey = row.Key.PrivateKeyPem; KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty; KeyEditorPublicKey = row.Key.PublicKey ?? string.Empty; KeyEditorNotes = row.Key.Notes ?? string.Empty; IsEditingKey = true; Status = $"Editing {row.Label}."; } /// Abandons the key editor, clearing the material out of it. [RelayCommand] private void CancelKeyEdit() { IsEditingKey = false; editingKeyId = null; ClearKeyEditor(); Status = string.Empty; } /// Stores the key editor's contents, encrypted, and queues it for the server. [RelayCommand] private async Task SaveKeyAsync(CancellationToken cancellationToken) { var key = BuildKey(); if (!key.TryValidate(out var reason)) { Status = reason; return; } await RunAsync( "Saving…", async () => { if (editingKeyId is { } entityId) { await session.SshKeys .UpdateAsync(session.ActiveVaultId, entityId, key, cancellationToken) .ConfigureAwait(true); } else { editingKeyId = await session.SshKeys .CreateAsync(session.ActiveVaultId, key, cancellationToken) .ConfigureAwait(true); } IsEditingKey = false; ClearKeyEditor(); await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedKey = Keys.FirstOrDefault(row => row.EntityId == editingKeyId); editingKeyId = null; Status = connection() is null ? $"Saved '{key.Label}'. It will sync when you are online." : $"Saved '{key.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Queues a tombstone for the selected key. [RelayCommand] private async Task DeleteKeyAsync(CancellationToken cancellationToken) { if (SelectedKey is not { } row) { return; } await RunAsync( "Deleting…", async () => { await session.SshKeys .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted '{row.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Opens a terminal on the selected host. [RelayCommand] private async Task ConnectAsync(CancellationToken cancellationToken) { if (SelectedHost is not { } row) { Status = "Choose a host first."; return; } if (string.IsNullOrEmpty(row.Host.Username)) { Status = "This host has no username. Edit it and add one."; return; } if (TryBuildCredential(row.Host) is not { } credential) { // Refused rather than quietly falling back to the password box. A host set up for key-only // access that silently starts offering a password is the failure worth ruling out — the user // asked for one thing and got another, and the host is the last place that would say so. Status = $"'{row.Label}' authenticates with an SSH key that is not in this vault any more. " + "Edit the host to choose another key, or set it back to a password."; return; } PendingHostKey = null; HostKeyMismatch = null; await RunAsync( $"Connecting to {row.Label}…", () => OpenSessionAsync(row, credential, cancellationToken)).ConfigureAwait(true); } /// /// Pins the offered host key and retries. /// /// /// The pin goes into the vault, so this writes to the local cache and queues a change for every other /// machine — which is why the write is guarded and the connection is only retried once it has landed. /// It used to be a dictionary insert that could not fail; reporting a failed write as a failed /// connection would send the user looking at the host. /// [RelayCommand] private async Task TrustHostKeyAsync(CancellationToken cancellationToken) { if (PendingHostKey is not { } presentation) { return; } try { await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true); } catch (OperationCanceledException) { Status = "Cancelled."; return; } catch (Exception exception) { Status = $"The host key could not be stored, so nothing was connected: {exception.Message}"; return; } PendingHostKey = null; await ConnectAsync(cancellationToken).ConfigureAwait(true); // After connecting, not before. A pin is worth pushing straight away — the same host on another // machine should not ask again — but not at the cost of delaying the connection the user asked for. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Dismisses the trust prompt without pinning anything. [RelayCommand] private void RejectHostKey() { PendingHostKey = null; Status = "The host key was not trusted, so nothing was connected."; } /// /// Withdraws trust from every key pinned for the host being edited. /// /// /// /// The counterpart to trust that now outlives the process, and the reason it exists at all: a mismatch is /// a hard refusal with no way past it, so a server that is legitimately rebuilt would be unreachable for /// ever without this. It is deliberately here — in the host's editor, reached by choosing to edit /// a host — and not on the refusal itself. A "forget this key" button next to the warning is the same /// button as "continue anyway" with two clicks instead of one. /// /// /// Applies to the host's saved address rather than whatever the editor's boxes currently hold. /// The pin belongs to the endpoint that was actually dialled, and someone halfway through retyping a /// hostname has not moved it yet. /// /// [RelayCommand] private async Task ForgetHostKeyAsync(CancellationToken cancellationToken) { if (editingEntityId is not { } entityId || Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { return; } var address = row.Host.Hostname; var port = row.Host.Port; await RunAsync( $"Forgetting the pinned host key for {address}…", async () => { var forgotten = await knownHosts .ForgetAsync(address, port, cancellationToken) .ConfigureAwait(true); // The refusal that sent the user here is about a pin that no longer exists. HostKeyMismatch = null; PendingChanges = await session .PendingChangeCountAsync(cancellationToken) .ConfigureAwait(true); if (forgotten == 0) { Status = $"Nothing was pinned for {address}:{port}."; return; } Status = $"Forgot the pinned host key for {address}:{port}. The next connection will " + "ask you to check its fingerprint again."; }).ConfigureAwait(true); // Pushed straight away, as a save or a deletion is: a withdrawal that stayed on this machine would // leave the other ones refusing to connect to a server that has been rebuilt. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// /// Marks every shown conflict as seen. /// /// /// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who /// dismisses this and realises a minute later that they wanted the other value should still be able to /// get it. /// [RelayCommand] private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken) { foreach (var conflict in Conflicts.ToArray()) { await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true); } await LoadConflictsAsync(cancellationToken).ConfigureAwait(true); } /// public async ValueTask DisposeAsync() { if (disposed) { return; } disposed = true; // The loop is stopped and awaited before the session goes, not merely signalled. A pass in flight // holds the vault keys and the cache; letting it run on into a disposed session is how locking // turns into an ObjectDisposedException on a background thread that nobody sees. if (autoSync is not null) { await autoSync.CancelAsync().ConfigureAwait(false); } if (autoSyncLoop is not null) { await autoSyncLoop.ConfigureAwait(false); } autoSync?.Dispose(); syncGate.Dispose(); await session.DisposeAsync().ConfigureAwait(false); } /// /// The renderer has to be attached before a session opens: the transport drops frames when nothing is /// connected, so a session opened earlier would lose its SessionOpened frame and then stream /// output at a terminal that was never created. That wait is bounded and takes this command's token, so /// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…". /// private async Task OpenSessionAsync( HostRowViewModel row, SshCredential credential, CancellationToken cancellationToken) { try { await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true); var request = new SshConnectionRequest( row.Host.Hostname, row.Host.Port, row.Host.Username!, credential); await workspace .OpenSessionAsync(request, TerminalSize.Default, cancellationToken) .ConfigureAwait(true); Status = $"Connected to {row.Label}."; // Only now, and only on success. The page's own term.focus() focuses the textarea inside // the document, which does nothing while the window's keyboard focus is still on the // Connect button — so without this the first keystrokes of the session go to the shell's // UI instead of the remote shell. SessionOpened?.Invoke(this, EventArgs.Empty); } catch (TimeoutException) { // The renderer never attached, so nothing was connected. Reported here rather than left to // RunAsync's generic handler because TimeoutException says only "The operation has timed out", // and the one thing worth saying is where to look: a runtime this application does not install. Status = "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 " + "runtime is probably missing or blocked; install it and try again."; } catch (SshHostKeyUnknownException exception) { // First contact. The user has to decide, and they need the fingerprint to do it. PendingHostKey = exception.Presentation; Status = "This host has not been seen before."; } catch (SshHostKeyMismatchException exception) { HostKeyMismatch = exception.Message; Status = "The host key has changed. The connection was refused."; } } /// /// How this host authenticates, or null when it names a key the vault does not have. /// /// /// /// The key material is handed over as UTF-8 bytes, which is what PrivateKeyFile reads from a /// MemoryStream — so the key reaches SSH.NET without ever becoming a file on disk. The /// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point /// of a vault, and SshKeySecret says why. It is passed straight through with no empty-to-null /// check, because SshKeySecret.Passphrase cannot hold an empty string. /// /// /// Null is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means a /// key was deleted on another machine — plausible, and no reason to start sending a password to a host /// somebody deliberately set up not to accept one. /// /// private SshCredential? TryBuildCredential(HostSecret host) { if (host.SshKeyId is not { } keyId) { return new SshPasswordCredential(ConnectPassword); } if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key) { return null; } return new SshPrivateKeyCredential( Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase); } private HostSecret BuildHost() => new() { Label = EditorLabel.Trim(), Hostname = EditorHostname.Trim(), Port = EditorPort, Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(), Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes, RelayEnabled = EditorRelayEnabled, // Whatever the picker holds, including the id of a key that has gone missing. Reading it from // the picker rather than carrying the original through is what lets a binding be removed at all, // and preserving a missing id is what stops an unrelated edit removing one by accident. SshKeyId = EditorSelectedKey?.EntityId, }; /// /// Fills the key picker, keeping whatever the host is currently bound to selectable. /// /// The key the host names, or null for password authentication. /// /// A bound key that is no longer in the vault gets a placeholder entry rather than being dropped. Without /// one the picker would open on "Password (no key)", and someone editing the host's port would convert it /// to password authentication by saving — which is the quiet version of the failure the connect path /// refuses outright. /// private void BuildKeyChoices(Guid? boundKeyId) { EditorKeyChoices.Clear(); EditorKeyChoices.Add(SshKeyChoice.None); foreach (var key in Keys) { EditorKeyChoices.Add(new SshKeyChoice(key.EntityId, key.Label)); } if (boundKeyId is { } bound && EditorKeyChoices.All(choice => choice.EntityId != bound)) { EditorKeyChoices.Add(SshKeyChoice.Missing(bound)); } EditorSelectedKey = EditorKeyChoices.FirstOrDefault(choice => choice.EntityId == boundKeyId) ?? SshKeyChoice.None; } /// /// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up /// would eventually tidy a format it did not fully understand — the same reason /// SshKeySecret.PrivateKeyPem stores it verbatim. Everything else is trimmed, because a label /// with a trailing space sorts oddly and reads as a different name. /// private SshKeySecret BuildKey() => new() { Label = KeyEditorLabel.Trim(), PrivateKeyPem = KeyEditorPrivateKey, // Not trimmed and not emptied: leading or trailing spaces are legitimate in a passphrase, and // the record turns an empty one into null on its own. Passphrase = KeyEditorPassphrase, PublicKey = string.IsNullOrWhiteSpace(KeyEditorPublicKey) ? null : KeyEditorPublicKey.Trim(), Notes = string.IsNullOrWhiteSpace(KeyEditorNotes) ? null : KeyEditorNotes, }; /// /// Whether the key editor has to be dealt with before another one can open. /// /// /// /// Only one editor open at a time, and this is a layout constraint rather than a style rule. Both /// editors sit in the same 340-pixel column as Auto rows, and their desired heights together /// exceed the column at the window's minimum height — so opening both pushes the lower one's Save and /// Cancel past the bottom edge, where they cannot be clicked. That is the same failure this window has /// already shipped once, when the setup screens rendered sliced with their buttons unreachable, and it is /// the failure that nothing in this repository can catch: no test loads a .axaml. Making it a /// state rule instead of a sizing hope is what makes it testable at all. /// /// /// Refused rather than resolved by closing the other editor, because closing it would silently discard /// what was typed there — and in the key editor that is a pasted private key the user may have nowhere /// else. One sentence and one click is the cheaper of the two. /// /// private bool KeyEditorIsInTheWay() { if (!IsEditingKey) { return false; } Status = "Finish or cancel the SSH key you are editing first."; return true; } /// private bool HostEditorIsInTheWay() { if (!IsEditing) { return false; } Status = "Finish or cancel the host you are editing first."; return true; } private void ClearKeyEditor() { KeyEditorLabel = string.Empty; KeyEditorPrivateKey = string.Empty; KeyEditorPassphrase = string.Empty; KeyEditorPublicKey = string.Empty; KeyEditorNotes = string.Empty; } private async Task LoadConflictsAsync(CancellationToken cancellationToken) { var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true); Conflicts.Clear(); foreach (var notice in notices) { Conflicts.Add(new ConflictRowViewModel(notice)); } OnPropertyChanged(nameof(HasConflicts)); } /// /// Deliberately reports the things a user has to act on rather than a count of successes. A pass that /// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point /// of recording those is that somebody sees them. /// private static string Describe(SyncReport report) { if (!report.NeedsAttention) { return report.Pulled == 0 && report.Pushed == 0 ? "Already up to date." : $"Synchronised: {report.Pulled} in, {report.Pushed} out."; } var notes = new List(); // "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind // would send someone looking through the wrong list for something that was not there. if (report.Resurrected > 0) { notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name"); } if (report.DeletesAbandoned > 0) { notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit"); } if (report.Parked > 0) { notes.Add($"{report.Parked} change(s) were refused and need attention"); } if (report.Unreadable > 0) { notes.Add($"{report.Unreadable} item(s) could not be decrypted"); } if (report.RekeyRequired) { notes.Add("this vault was rekeyed and your access needs re-issuing"); } return "Synchronised, but: " + string.Join("; ", notes) + "."; } private async Task RunAsync(string busyMessage, Func work) { if (IsBusy) { return; } IsBusy = true; Status = busyMessage; try { await work().ConfigureAwait(true); } catch (OperationCanceledException) { Status = "Cancelled."; } catch (Exception exception) { Status = exception.Message; } finally { IsBusy = false; } } partial void OnSelectedHostChanged(HostRowViewModel? value) => OnPropertyChanged(nameof(SelectedHostUsesAKey)); /// /// The editing id is set before this flips in every path that opens the editor, and cleared after it /// flips back in every path that closes one, so this notification always observes the pair in a /// consistent state. /// partial void OnIsEditingChanged(bool value) => OnPropertyChanged(nameof(CanForgetHostKey)); partial void OnPendingHostKeyChanged(HostKeyPresentation? value) => OnPropertyChanged(nameof(HasPendingHostKey)); partial void OnHostKeyMismatchChanged(string? value) => OnPropertyChanged(nameof(HasHostKeyMismatch)); }