Public Access
Make Connections the place a connection is made, and put the keychain away
Four changes to the phone, and the last one needed the connect path taking apart. **The bottom bar is three entries.** The keychain moved onto the hub, which is now SETTINGS with a gear rather than MORE with a hamburger. A bottom bar is for the places a session moves between, and keys, credentials and tags are managed occasionally and then left alone — which is the shape of everything already behind that hub. With the keychain on it, "more" stopped being a description of what is there. `ShellScreen.Vault` joining `IsMoreSurface` is the whole of the change: the tab that lights, the header that stands down and the back gesture's first case all read that one property, which is why the switch mirrors it by construction rather than by a second list. The keychain screen grew the header every hub screen has, because the shell's own is not above it any more and without one there would be no back arrow and nothing saying what the list is. The desktop keeps its Keychain rail entry. A rail with nine slots has room, so this is the second thing the two heads arrange deliberately differently, after the hub itself. **Terminal became Connections**, and the word does more work than a rename usually does — see below. The enum member stays `ShellSurface.Terminal`, for the reason the tab was never called Vault: the surface is a terminal, and the word a user reads is the product's. **The + puts the software keyboard away.** It sits above a terminal somebody is typing into, so the sheet it raises was arriving underneath a keyboard covering the half of the screen the sheet is on — and worse, laid out into the strip left above it, since the keyboard's inset shortens everything this head draws. Avalonia cannot do this and it is worth knowing why: `TopLevel.InputPane` reports the keyboard and offers nothing that closes one, because the framework's model is that it belongs to whatever has focus — and this keyboard was raised by the `WebView`'s own text input, by a native view Avalonia's focus manager never owned. Clearing Avalonia's focus leaves it exactly where it is. So `Platform/SoftKeyboard.cs` asks `InputMethodManager`, off the decor view's window token, and every step of it is allowed to be absent. **With nothing open, Connections is a connect screen rather than an empty state.** A box taking `user@host` or `user@host:port`, a password, and the machines most recently connected to underneath. The box is the only path in this product to a machine the keychain has never heard of, which is a real case it had no answer for: an address somebody was handed five minutes ago. A typed password and nothing else — offering the keychain's keys would be a second binding resolution beside `TryBuildAuthentication`, and the argument against a second one is written there at length. Nothing typed is saved, and the screen says so: a machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name. The recents come out of the vault's own connection log rather than a list kept in this process, so they survive a restart and arrive on a new phone with the keychain. Deduplicated by address, because this is a list of places and not of events, and capped at six so the box stays above the keyboard. Emptied when the vault is — they are decrypted entries naming where somebody works, and a lock that left them on screen would be a list still readable after every key that decrypted it was zeroed. Tapping one leads to whichever of two things it is: a keychain host goes to that host's connect bar, where its key, its password box and its refusals already live, and an address goes back into the box, without the password, whose absence is the point of that path rather than a gap in it. **The connect path was shaped like `HostRowViewModel` all the way down.** The log entry, the identification, the failure record and the retry all took a row. They take a four-field `ConnectionTarget` now, so a connection to an address shares the ladder of refusals, the host-key question and the tab's lifecycle rather than growing a second copy of them. `ConnectionRecorder.Record` and `Identify` have always taken a nullable host id, so the log could already hold a connection with no item behind it. One behavioural change falls out of that and it is the one to know about: **trusting a host key now retries the attempt that raised the question** instead of re-running whichever host is selected. That was correct while a selected host was the only way to connect; with a manual target it would dial a different machine, or refuse with "choose a host first" over a key the user has just agreed to trust. The test selects a host first, so a regression cannot pass by connecting to the wrong thing successfully. `LogsViewModel.ReloadAsync` split so the connections half can be read alone. Reading the keychain's activity for a screen that offers neither would double the decryption on the list that was already the expensive one. Twelve tests: the parse grammar as a theory over seven refusals, the dialled request, the retry, and both branches of tapping a recent row. The recents rows are built by hand rather than connected-and-closed — what those tests are about is which branch a row takes, and driving it through the recorder's queue would test the recorder, which `DodoSSH.Client.Session.Tests` already does. What needs a device is phases 11.6 to 11.9 of `docs/manual-checks.md`.
This commit is contained in:
@@ -1992,6 +1992,44 @@ internal sealed partial class VaultViewModel(
|
||||
[ObservableProperty]
|
||||
private string connectPassword = string.Empty;
|
||||
|
||||
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
|
||||
/// <remarks>
|
||||
/// One box rather than four, because this is the form of an address people already have: it is what a
|
||||
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
|
||||
/// would make the ordinary case three taps between three keyboards on a phone.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualTarget = string.Empty;
|
||||
|
||||
/// <inheritdoc cref="ConnectPassword" />
|
||||
/// <remarks>
|
||||
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
|
||||
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
|
||||
/// not a password offered on the other.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualPassword = string.Empty;
|
||||
|
||||
/// <summary>Why the manual box refused, if it did.</summary>
|
||||
/// <remarks>
|
||||
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
|
||||
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
|
||||
/// the text box, not on a status line that also carries what the sync engine is doing.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string manualStatus = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Set on every attempt rather than only on the ones that stop, because whether a question is coming is
|
||||
/// not knowable until the handshake has run. It is never cleared: a stale pair costs nothing, since the
|
||||
/// only thing that reads it is a trust decision, and one of those can only exist for the attempt that
|
||||
/// raised it.
|
||||
/// </remarks>
|
||||
private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the selected host will want something typed into the password box.
|
||||
/// </summary>
|
||||
@@ -4911,21 +4949,194 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
await ConnectToAsync(
|
||||
new ConnectionTarget(row.Label, row.EntityId, row.Host.Hostname, row.Resolved.Port.Value),
|
||||
authentication,
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a terminal on somewhere that is not in the keychain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
|
||||
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
|
||||
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
|
||||
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
|
||||
/// been given the address of.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
|
||||
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
|
||||
/// written there at length. A key is a reason to save the host.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
|
||||
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
|
||||
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||||
/// <see cref="ConnectAsync"/> carries.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
|
||||
|
||||
/// <inheritdoc cref="ConnectManuallyAsync()" />
|
||||
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
|
||||
{
|
||||
ManualStatus = refusal;
|
||||
return;
|
||||
}
|
||||
|
||||
if (ManualPassword.Length == 0)
|
||||
{
|
||||
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
|
||||
return;
|
||||
}
|
||||
|
||||
ManualStatus = string.Empty;
|
||||
|
||||
await ConnectToAsync(
|
||||
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
|
||||
// different connections, and a strip showing the same name twice would be the tab equivalent of
|
||||
// the log entry this also names.
|
||||
new ConnectionTarget(
|
||||
$"{endpoint.Username}@{endpoint.Hostname}",
|
||||
HostId: null,
|
||||
endpoint.Hostname,
|
||||
endpoint.Port),
|
||||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
|
||||
cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads <c>user@host</c>, with an optional <c>:port</c>, or says why it cannot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The username is required rather than defaulted to this device's account name, which is what
|
||||
/// <c>ssh</c> itself would do. A phone has no account name worth borrowing — the value there is the
|
||||
/// Android user, which is never a login on anything — so a default would be a guess that fails at the
|
||||
/// remote with "authentication failed" rather than here with a sentence.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The port defaults to 22 and refuses anything outside 1–65535, which is the range
|
||||
/// <c>HostSecret.TryValidate</c> already enforces for a saved host. A target that cannot be stored is
|
||||
/// not one this path should be able to dial either.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// IPv6 in brackets is not accepted, and the refusal says so rather than silently reading
|
||||
/// <c>::1</c>'s last colon as a port separator. Nothing else in this application accepts a bracketed
|
||||
/// address — <c>HostSecret.Hostname</c> is a bare string dialled as it stands — so accepting one here
|
||||
/// would make this the only field in the product with its own address grammar.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool TryParseManualTarget(
|
||||
string typed,
|
||||
[NotNullWhen(true)] out ManualEndpoint? endpoint,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
endpoint = null;
|
||||
|
||||
var trimmed = typed.Trim();
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
reason = "Type a machine to connect to, as user@host.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed.Contains('[', StringComparison.Ordinal))
|
||||
{
|
||||
reason = "A bracketed IPv6 address is not accepted here. Save it as a host instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var at = trimmed.LastIndexOf('@');
|
||||
|
||||
if (at <= 0 || at == trimmed.Length - 1)
|
||||
{
|
||||
reason = "Say who to log in as: user@host, or user@host:port.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var username = trimmed[..at];
|
||||
var host = trimmed[(at + 1)..];
|
||||
var port = 22;
|
||||
|
||||
if (host.LastIndexOf(':') is var colon && colon >= 0)
|
||||
{
|
||||
if (!int.TryParse(
|
||||
host[(colon + 1)..],
|
||||
NumberStyles.None,
|
||||
CultureInfo.InvariantCulture,
|
||||
out port)
|
||||
|| port is < 1 or > 65535)
|
||||
{
|
||||
reason = "The port has to be a number between 1 and 65535.";
|
||||
return false;
|
||||
}
|
||||
|
||||
host = host[..colon];
|
||||
}
|
||||
|
||||
if (host.Length == 0)
|
||||
{
|
||||
reason = "Say which machine: user@host, or user@host:port.";
|
||||
return false;
|
||||
}
|
||||
|
||||
endpoint = new ManualEndpoint(username, host, port);
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
|
||||
/// this level — its account comes out of <see cref="TryBuildAuthentication"/>, possibly from a bound
|
||||
/// credential rather than from the host — so a username on the shared record would be a field that is
|
||||
/// null for every connection but this one.
|
||||
/// </remarks>
|
||||
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
|
||||
|
||||
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
|
||||
/// <remarks>
|
||||
/// One method rather than two, and it is the same argument <see cref="TryBuildConnectionRequest"/> makes
|
||||
/// about there being one authentication resolution: the ladder of refusals below this, the host-key
|
||||
/// question, the log entry and the tab's own lifecycle are the parts nobody should be able to get
|
||||
/// subtly different for one kind of connection.
|
||||
/// </remarks>
|
||||
private async Task ConnectToAsync(
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
// Remembered so that answering the host-key question retries *this* attempt. It used to re-run the
|
||||
// selected host unconditionally, which was right while that was the only way to connect and would
|
||||
// now dial the wrong machine — or refuse, with nothing selected — for a manual one.
|
||||
pendingRetry = (target, authentication);
|
||||
|
||||
// Before the first await, so the tab is in the strip in the same turn the user asked for it. The
|
||||
// address is the one that will actually be dialled — a bound credential can supply the username —
|
||||
// rather than the host's own fields, so the tab does not rename itself on connecting.
|
||||
var attempt = new ConnectionAttemptEventArgs(
|
||||
Guid.CreateVersion7(),
|
||||
row.Label,
|
||||
Dialled(row, authentication));
|
||||
target.Label,
|
||||
Dialled(target, authentication));
|
||||
|
||||
ConnectionStarting?.Invoke(this, attempt);
|
||||
Status = $"Connecting to {row.Label}…";
|
||||
Status = $"Connecting to {target.Label}…";
|
||||
|
||||
await OpenSessionAsync(attempt, row, authentication, cancellationToken).ConfigureAwait(true);
|
||||
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -4962,7 +5173,15 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
PendingHostKey = null;
|
||||
|
||||
await ConnectToSelectedHostAsync(cancellationToken).ConfigureAwait(true);
|
||||
// The attempt that raised the question, replayed as it stood. Re-running the selected host was
|
||||
// right while that was the only way to connect; with a manual target it would dial whichever host
|
||||
// happens to be selected, or refuse with "choose a host first" over a machine the user has just
|
||||
// agreed to trust.
|
||||
if (pendingRetry is { } retry)
|
||||
{
|
||||
await ConnectToAsync(retry.Target, retry.Authentication, 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.
|
||||
@@ -5118,13 +5337,13 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private async Task OpenSessionAsync(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
HostRowViewModel row,
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ConnectAndAnnounceAsync(attempt, row, authentication, cancellationToken)
|
||||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
@@ -5141,7 +5360,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
RecordFailure(row, authentication, ConnectionOutcome.Refused);
|
||||
RecordFailure(target, authentication, ConnectionOutcome.Refused);
|
||||
|
||||
HostKeyMismatch = exception.Message;
|
||||
Answer(attempt, "The host key has changed. The connection was refused.");
|
||||
@@ -5152,7 +5371,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
RecordFailure(row, authentication, ConnectionOutcome.Failed);
|
||||
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
||||
Abandon(attempt, exception.Message);
|
||||
}
|
||||
}
|
||||
@@ -5197,15 +5416,15 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private async Task ConnectAndAnnounceAsync(
|
||||
ConnectionAttemptEventArgs attempt,
|
||||
HostRowViewModel row,
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
var request = new SshConnectionRequest(
|
||||
row.Host.Hostname,
|
||||
Resolve(row.Host).Port.Value,
|
||||
target.Hostname,
|
||||
target.Port,
|
||||
authentication.Username,
|
||||
authentication.Credential);
|
||||
|
||||
@@ -5216,10 +5435,11 @@ internal sealed partial class VaultViewModel(
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
||||
// notion of one — so the name is added here rather than the ticket being replaced, which would move
|
||||
// the start time to now.
|
||||
connectionLog?.Identify(sessionId, row.Label, row.EntityId);
|
||||
// the start time to now. A target with no item still gets its name, and a null id: the entry is the
|
||||
// only record that machine was reached at all.
|
||||
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
Status = $"Connected to {target.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
|
||||
@@ -5229,8 +5449,8 @@ internal sealed partial class VaultViewModel(
|
||||
new TerminalSessionEventArgs(
|
||||
attempt.AttemptId,
|
||||
sessionId,
|
||||
row.Label,
|
||||
Dialled(row, authentication)));
|
||||
target.Label,
|
||||
Dialled(target, authentication)));
|
||||
}
|
||||
|
||||
/// <summary>The address as actually dialled.</summary>
|
||||
@@ -5241,10 +5461,10 @@ internal sealed partial class VaultViewModel(
|
||||
/// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log
|
||||
/// naming a port nothing dialled is worse than no log.
|
||||
/// </remarks>
|
||||
private static string Dialled(HostRowViewModel row, HostAuthentication authentication) =>
|
||||
private static string Dialled(ConnectionTarget target, HostAuthentication authentication) =>
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{authentication.Username}@{row.Host.Hostname}:{row.Resolved.Port.Value}");
|
||||
$"{authentication.Username}@{target.Hostname}:{target.Port}");
|
||||
|
||||
/// <summary>
|
||||
/// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
|
||||
@@ -5298,16 +5518,16 @@ internal sealed partial class VaultViewModel(
|
||||
/// the duration is zero and the outcome carries the meaning.
|
||||
/// </remarks>
|
||||
private void RecordFailure(
|
||||
HostRowViewModel row,
|
||||
ConnectionTarget target,
|
||||
HostAuthentication authentication,
|
||||
ConnectionOutcome outcome)
|
||||
{
|
||||
var at = TimeProvider.System.GetUtcNow();
|
||||
|
||||
connectionLog?.Record(
|
||||
Dialled(row, authentication),
|
||||
row.Label,
|
||||
row.EntityId,
|
||||
Dialled(target, authentication),
|
||||
target.Label,
|
||||
target.HostId,
|
||||
ConnectionKind.Terminal,
|
||||
at,
|
||||
at,
|
||||
@@ -5327,6 +5547,22 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private sealed record HostAuthentication(string Username, SshCredential Credential);
|
||||
|
||||
/// <summary>
|
||||
/// The machine a connection is being made to, however it was named.
|
||||
/// </summary>
|
||||
/// <param name="Label">What to call it — a keychain host's alias, or what was typed.</param>
|
||||
/// <param name="HostId">The keychain item, or null for somewhere that is not in it.</param>
|
||||
/// <param name="Hostname">The address to dial.</param>
|
||||
/// <param name="Port">The port to dial, already resolved through any group.</param>
|
||||
/// <remarks>
|
||||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||||
/// below the resolution needs four facts and a row carries dozens; taking the four is what let a
|
||||
/// connection to an address that has no keychain item share the ladder rather than grow a second one.
|
||||
/// <see cref="ConnectionRecorder.Record"/> and <c>Identify</c> both take a nullable id already, so the
|
||||
/// log has always been able to hold a connection with no item behind it.
|
||||
/// </remarks>
|
||||
private sealed record ConnectionTarget(string Label, Guid? HostId, string Hostname, int Port);
|
||||
|
||||
/// <summary>
|
||||
/// Works out how a host authenticates, or says why it cannot.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user