Compare commits

...
Author SHA1 Message Date
jaap-jan 858e36279d Sync: after a read from the beginning, remove what the server no longer has, and flag an edit that brings a forgotten item back
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 48s
The server now forgets a deletion past its retention and refuses cursors
from before it. The client then reads everything again, but everything
again says nothing about what went. So a machine away for longer than the
retention kept those items for good, and its next edit of one put it back
on the server for everyone.

A read from the beginning now marks each item it reads, keeping the mark in
the sync state because the read can span passes. When it finishes, it
removes the items of the synced types it never marked. Two kinds are kept:
items newer than the read's bound, which this machine pushed during it, and
items with a change still waiting, which are the user's work. For those,
the push finds the server without the item and sends it as a create, as
before, but now records a RecreatedAfterCollection conflict and counts it,
so the sync message says so instead of undoing someone's deletion silently.

The bound is the highest sequence the cache held when the read began, or
anything higher it has read since. Taking only what the read returned would
have left the last item of a vault in place for ever.
2026-10-02 17:36:11 +02:00
jaap-jan 55e5de0ce4 Server: a cursor carries the sync horizon it was issued under, so a read from the beginning after a collection is not refused on its own pages
A cursor behind the horizon was refused on position alone. But the read a
refused client starts from the beginning is itself behind the horizon on
every page until it passes the collected deletion, since older untouched
items come first. So its second page was refused, it started over, and it
never finished, on any vault larger than one page.

The cursor now records the horizon read before the page that produced it,
and only a cursor issued under an older horizon is refused. A cursor issued
under no horizon keeps the v1 format, so a server from before this still
reads it.
2026-10-02 17:36:03 +02:00
jaap-jan 8cb1457ca9 Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection 2026-10-02 17:18:22 +02:00
jaap-jan 656a33b4b1 Desktop: build only the host cards near the view, with a virtualising wrap panel for the board
ci / build and test (push) Successful in 3m39s
ci / android head (push) Successful in 3m59s
ci / api image (push) Successful in 5s
ci / desktop nightly (push) Failing after 37s
2026-10-02 16:20:51 +02:00
jaap-jan 41f368ae75 Merge remote-tracking branch 'origin/main' into claude/audit-followups
ci / build and test (push) Successful in 3m45s
ci / android head (push) Successful in 4m15s
ci / desktop nightly (push) Failing after 43s
ci / api image (push) Successful in 46s
2026-10-02 15:27:39 +02:00
jaap-jan 24c18205c3 Desktop: rebuild the hosts board once the typing in the find box pauses, not once a letter 2026-10-02 15:23:58 +02:00
jaap-jan 8759b12d47 Desktop: virtualise the transfer queue and the import lists 2026-10-02 15:14:34 +02:00
jaap-jan 7b072bcded Desktop: swap the host lists in whole instead of announcing every row to listeners that redo all their work per row 2026-10-02 15:07:08 +02:00
jaap-jan b1d4a270d4 Merge pull request 'Audit follow-ups: keyboard, wording, performance, transfer, sync, retry and relay fixes' (#22) from claude/audit-followups into main
ci / build and test (push) Failing after 3m10s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 4m7s
Reviewed-on: #22
2026-10-02 12:59:55 +00:00
jaap-jan a2362d1e3e Desktop: decide a titlebar double-click on the press, before the drag can swallow it
ci / build and test (pull_request) Successful in 2m48s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 13s
ci / android head (pull_request) Successful in 3m41s
2026-10-02 14:40:50 +02:00
jaap-jan 079a273d46 Desktop: let a lock pressed during an unlock win, instead of the unlock finishing behind it 2026-10-02 14:37:52 +02:00
jaap-jan 37e9d74938 Desktop: keep a vault-visibility failure from closing the app, and leave a crash log when anything else does 2026-10-02 14:28:27 +02:00
jaap-jan 170caa1391 Relay: end a user's sessions into a vault the moment they lose access to it 2026-10-02 14:26:44 +02:00
jaap-jan 965c9a7043 Server: stop a team being archived while a vault is created in it 2026-10-02 14:20:30 +02:00
jaap-jan ff8a790f6c Sync: take the server having no such item as a delete done, not a rejection nobody can clear 2026-10-02 14:18:28 +02:00
jaap-jan b2516ca7fb Server: start a retried write transaction from the database, not from the attempt that failed 2026-10-02 14:15:52 +02:00
jaap-jan 36fb0817a4 Transfers: say so when a drop or a download is refused because the screen is busy 2026-10-02 14:11:41 +02:00
jaap-jan cc3d46f78c Transfers: let cancel pressed as a transfer finishes do nothing rather than throw 2026-10-02 14:10:40 +02:00
jaap-jan 8bee476039 Transfers: stop a transfer queued as the last one finishes from waiting for ever 2026-10-02 14:09:47 +02:00
jaap-jan 441709f1e9 Desktop: stop decrypting the whole keychain on a sync pass that changed nothing 2026-10-02 14:08:08 +02:00
jaap-jan 873405e591 Sync: mirror a pulled page in one write and read the outbox once per page, not twice per change 2026-10-02 14:04:36 +02:00
jaap-jan f997e87c5f SSH: open a shell off the caller's thread, and stop waiting when the caller gives up 2026-10-02 14:02:18 +02:00
jaap-jan 365954c541 Desktop: probe for the TPM once, off the UI thread, instead of on every unlock 2026-10-02 14:00:12 +02:00
jaap-jan 3ccafdd53b Desktop: drop the VAULT columns that had nothing in them, and the snippet buttons with nothing to act on 2026-10-02 13:58:07 +02:00
jaap-jan 22b4d82ba8 Desktop: stop the status bar's session timer when the shell ends 2026-10-02 13:54:24 +02:00
jaap-jan 619f49a35a Desktop: trim a long session tab label instead of letting it push the + off the strip 2026-10-02 13:54:24 +02:00
jaap-jan a6d3924e0d Desktop: call device unlock what this platform calls it, not Windows Hello everywhere 2026-10-02 13:52:00 +02:00
jaap-jan 079914bfb4 Desktop: let the arrows choose the host the drawer acts on, and Enter connect to it 2026-10-02 13:49:36 +02:00
jaap-jan 6c09bdecb6 Desktop: give the host-key, delete and sign-out questions the keyboard on their harmless answer, and let Escape say no 2026-10-02 13:45:40 +02:00
jaap-jan 372a7cbdc7 Desktop: put the keyboard in the closed vault's card on launch, and let Enter sign in and enrol 2026-10-02 13:39:12 +02:00
jaap-jan a4b81da597 Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
ci / build and test (push) Successful in 3m26s
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 17s
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
  panel kept showing the previous vault's people under the new name. The read now starts regardless;
  the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
  title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
  leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
  ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
  workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
  Keychain names trim inside their column instead of printing over TYPE, and the Security page's
  caption wraps inside its card.
2026-10-02 13:16:55 +02:00
jaap-jan 0e8fe4fc80 Give each profile its own device key, and make the Linux PIN window able to open at all
- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of
  the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the
  existing name for the default profile and gives every other profile its own; sign-out deletes only
  its own key, and a profile sealed before this keeps reading the shared one.
- DevicePinWindow named two palette resources that do not exist and loaded itself with
  AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no
  PIN prompt was ever shown. A layout test now builds it against the real palette.
2026-10-02 13:16:48 +02:00
jaap-jan 2ff9dff560 Terminal: deliver large pastes whole, keep keystrokes in order, and keep one session's failure off the shared socket
- A message larger than the 64 KiB receive buffer was skipped and its tail dispatched as a frame of its
  own. Messages are now reassembled up to 16 MiB; one beyond that is dropped whole.
- Input frames are handed to the pump without waiting, and ShellStream takes no lock around its write
  buffer, so a snippet's text and its carriage return could interleave or lose bytes. Writes are
  serialised per pump in arrival order.
- A resize reaching a shell SSH.NET had already disposed threw out of the receive loop and closed the
  socket every tab shares. Each frame's failure now stays with its session.
2026-10-02 13:16:48 +02:00
jaap-jan 6846f67c20 Server: send each item at the revision its ciphertext is sealed at, keep team archiving with the owner, refuse suspended accounts, and stop enrolment failing after an hour
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's
  old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and
  parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the
  later entry carries the same payload under the right number, and a fresh device no longer downloads
  an item once per edit it ever had.
- Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out
  from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks.
- UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers
  403 account-not-active for a suspended or deprovisioned account.
- The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user
  against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The
  entity is reloaded after the update.
2026-10-02 13:16:48 +02:00
jaap-jan f332d9a56f Stop a finished push deleting the edit made while it was in flight, and pull a newly synced type from the start
- The outbox coalesces a new save into the row a push already read, under a new operation id, but
  Complete, Revise and Park found the row by sequence alone. The acknowledgement of the earlier state
  deleted the row holding the later one and the edit was gone without a word. They now take the
  PendingOperation and act only while the row still holds it; a later edit is rebased onto the version
  just accepted, so it pushes cleanly instead of conflicting with its own earlier save.
- A pull filtered by type moves the cursor past changes of types it did not ask for, so a build that
  added a type (SpelunkKey, ObjectStore) never received the items of that type made before it.
  sync_state.pulled_types records the list a cursor was read under, and a different list starts the
  pull over once. Migration AddSyncStatePulledTypes.
2026-10-02 13:16:47 +02:00
jaap-jan 03c8e8589a Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive
  letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided
  where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the
  resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot
  be a file there is left on the host and reported.
- EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run
  it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on
  macOS, and refuses a .desktop launcher on Linux.
- A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so
  the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue
  starts those over.
- The SFTP host picker re-found its selection from the empty moment of every vault reload and landed
  on the first host once a minute; the chosen host is now remembered, and a typed password is only
  sent to the host it was typed for.
2026-10-02 13:16:30 +02:00
jaap-jan 4c7f47c026 Refuse a pinned host that answers with a key of another type, rather than asking whether to trust it
Pins are stored per algorithm, so an endpoint pinned for ssh-ed25519 that was answered with only an
ECDSA or RSA key found no pin and raised SshHostKeyUnknownException: the first-use question, where a
pin exists to produce a hard refusal. Anybody in the middle needed nothing more than a key of a
different type.

The connection factory now reads every pin for the endpoint before the handshake, offers the pinned
algorithms first so a server that still holds its pinned key negotiates it, and in the gate accepts a
key under an unpinned algorithm only when its fingerprint matches one of the endpoint's pins (one RSA
key signs as rsa-sha2-256 and rsa-sha2-512). Anything else is a mismatch. IKnownHostStore gains
FindForEndpointAsync for this.
2026-10-02 13:16:30 +02:00
jaap-jan 412c314aec Merge pull request 'Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address' (#21) from claude/spelunk-api-key-security-0dfad0 into main
ci / desktop nightly (push) Successful in 1m38s
ci / build and test (push) Successful in 4m32s
ci / android head (push) Successful in 4m54s
ci / api image (push) Successful in 24s
Reviewed-on: #21
2026-09-29 08:36:08 +00:00
142 changed files with 9656 additions and 547 deletions

No files matched your search

+9 -1
View File
@@ -68,7 +68,15 @@ the Npgsql connection string** — the default; do not enable multiplexing.
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.** visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be - Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
able to read tombstones, which is the one place that legitimately bypasses the soft-delete able to read tombstones, which is the one place that legitimately bypasses the soft-delete
query filter — guarded by an explicit permission check. query filter — guarded by an explicit permission check. **The collection has since been built**
(`TombstoneCollector`), and it costs a machine away for longer than the retention its news of
those deletions. So a collection raises the vault's sync horizon, and a cursor from before it is
refused. The client then reads from the beginning, and when that read finishes it removes the
items it held that the read never mentioned. An item with an unsent local edit is kept: the
push finds the server without it, puts it back, and records a `RecreatedAfterCollection`
conflict. A cursor carries the horizon it was issued under. Without that, the read from the
beginning would be refused on its own early pages, which sit behind the horizon whenever an
older item survives.
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.** - **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
Without it this ADR's central bug is invisible until production. Without it this ADR's central bug is invisible until production.
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic - Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
@@ -0,0 +1,49 @@
using DodoSSH.Domain;
using Microsoft.AspNetCore.Authorization;
namespace DodoSSH.Api.Authorization;
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
/// <remarks>
/// <para>
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
/// that no endpoint can be reached without it.
/// </para>
/// <para>
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
/// reached by this; it ends on its own idle and lifetime limits.
/// </para>
/// </remarks>
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
: AuthorizationHandler<ActiveAccountRequirement>
{
/// <inheritdoc />
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveAccountRequirement requirement)
{
ArgumentNullException.ThrowIfNull(context);
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
// as well would turn that into a 403.
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
if (user.Status == UserStatus.Active)
{
context.Succeed(requirement);
}
}
}
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping /// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would /// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict. /// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes /// <para>
/// nothing, and marking it modified would enlist the user row in whatever the request saves next. /// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
/// against the version from before the update and failed, so enrolling more than an hour after first
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
/// </para>
/// </remarks> /// </remarks>
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken) private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
{ {
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
return; return;
} }
await database.Users var written = await database.Users
.Where(u => u.Id == user.Id .Where(u => u.Id == user.Id
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow)) && (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
.ExecuteUpdateAsync( .ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now), setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (written > 0)
{
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
}
} }
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) => private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(authorizeResult); ArgumentNullException.ThrowIfNull(authorizeResult);
var accountNotActive = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } refusal
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
if (accountNotActive)
{
await TypedResults.Problem(
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
statusCode: StatusCodes.Status403Forbidden,
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
{
["code"] = ProblemCodes.AccountNotActive,
})
.ExecuteAsync(context)
.ConfigureAwait(false);
return;
}
var enrollmentMissing = authorizeResult.Forbidden var enrollmentMissing = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } failure && authorizeResult.AuthorizationFailure is { } failure
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any(); && failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
@@ -0,0 +1,24 @@
using DodoSSH.Api.Features.Relay;
namespace DodoSSH.Api.Features.Events;
/// <summary>
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
/// </summary>
/// <remarks>
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
/// touch any of the places a removal happens.
/// </remarks>
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
{
/// <inheritdoc />
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
/// <inheritdoc />
public void VaultAccessChanged(Guid userId)
{
hub.VaultAccessChanged(userId);
_ = relay.AccessChangedAsync(userId);
}
}
@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Crypto; using DodoSSH.Crypto;
using DodoSSH.Domain; using DodoSSH.Domain;
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
byte[] fingerprint, byte[] fingerprint,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var strategy = database.Database.CreateExecutionStrategy(); return await database.ExecuteFreshAsync([user], async () =>
return await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -0,0 +1,62 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Domain.Authorization;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
/// </summary>
/// <remarks>
/// <para>
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
/// their keys and their vault list away at once, and left any shell they had open through the relay running
/// into the team's machines until it went idle.
/// </para>
/// <para>
/// Told by the same announcement that tells their event socket to re-read its vaults — see
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
/// has already committed and answered, and a slow re-check must not hold it.
/// </para>
/// </remarks>
internal sealed class RelayAccessWatch(
RelaySessionRegistry sessions,
IServiceScopeFactory scopes,
ILogger<RelayAccessWatch> logger)
{
/// <summary>Re-checks every session the user has open, in the background.</summary>
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
internal Task AccessChangedAsync(Guid userId)
{
var open = sessions.SessionsOf(userId);
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
}
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
foreach (var lease in open)
{
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
.ConfigureAwait(false);
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
{
lease.Revoke();
}
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
RelayLog.AccessRecheckFailed(logger, userId, exception);
}
}
}
@@ -83,7 +83,7 @@ internal sealed class RelayConnectEndpoint(
return; return;
} }
var lease = sessions.TryAdmit(ticket.UserId); var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
try try
{ {
@@ -113,7 +113,7 @@ internal sealed class RelayConnectEndpoint(
return; return;
} }
await RelayAsync(socket, ticket, ct).ConfigureAwait(false); await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
} }
finally finally
{ {
@@ -239,7 +239,11 @@ internal sealed class RelayConnectEndpoint(
} }
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary> /// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted) private async Task RelayAsync(
WebSocket socket,
RelayTicket ticket,
CancellationToken revoked,
CancellationToken requestAborted)
{ {
var started = clock.GetUtcNow(); var started = clock.GetUtcNow();
@@ -269,7 +273,7 @@ internal sealed class RelayConnectEndpoint(
{ {
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count); RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false); ending = await PumpAsync(socket, target, counters, started, revoked, requestAborted).ConfigureAwait(false);
} }
RelayLog.SessionClosed( RelayLog.SessionClosed(
@@ -338,13 +342,14 @@ internal sealed class RelayConnectEndpoint(
Stream target, Stream target,
Counters counters, Counters counters,
DateTimeOffset started, DateTimeOffset started,
CancellationToken revoked,
CancellationToken requestAborted) CancellationToken requestAborted)
{ {
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted); using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token); var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
var downstream = TargetToClientAsync(target, socket, counters, pump.Token); var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
var minding = MindAsync(counters, started, pump.Token); var minding = MindAsync(counters, started, revoked, pump.Token);
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false); var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
var ending = await first.ConfigureAwait(false) ?? new Ending( var ending = await first.ConfigureAwait(false) ?? new Ending(
@@ -512,7 +517,11 @@ internal sealed class RelayConnectEndpoint(
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so. /// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
/// </para> /// </para>
/// </remarks> /// </remarks>
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken) private async Task<Ending?> MindAsync(
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken cancellationToken)
{ {
var settings = options.Value; var settings = options.Value;
var hardDeadline = started + settings.MaxSessionDuration; var hardDeadline = started + settings.MaxSessionDuration;
@@ -527,27 +536,11 @@ internal sealed class RelayConnectEndpoint(
drainDeadline = now + settings.DrainTimeout; drainDeadline = now + settings.DrainTimeout;
} }
if (now >= hardDeadline)
{
return new Ending(
RelayCloseReason.DurationLimit,
RelayProtocol.DurationLimitCloseCode,
"This relay session reached its maximum lifetime.");
}
if (drainDeadline is { } drain && now >= drain)
{
return ShuttingDown;
}
var idleDeadline = counters.LastActivity + settings.IdleTimeout; var idleDeadline = counters.LastActivity + settings.IdleTimeout;
if (now >= idleDeadline) if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
{ {
return new Ending( return ending;
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.");
} }
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min(); var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
@@ -555,8 +548,8 @@ internal sealed class RelayConnectEndpoint(
// Woken early by the start of a shutdown, so the drain is measured from when it began rather // Woken early by the start of a shutdown, so the drain is measured from when it began rather
// than from whenever the idle clock next came round. // than from whenever the idle clock next came round.
using var wake = drainDeadline is null using var wake = drainDeadline is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping) ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
try try
{ {
@@ -564,7 +557,7 @@ internal sealed class RelayConnectEndpoint(
} }
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{ {
// The shutdown began. Round again to start the drain. // The shutdown began, or access was withdrawn. Round again to act on whichever it was.
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@@ -575,6 +568,50 @@ internal sealed class RelayConnectEndpoint(
return null; return null;
} }
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
/// <remarks>
/// Revocation first, because it is the one reason here that is about the user rather than the session:
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
/// </remarks>
private static Ending? Due(
DateTimeOffset now,
bool revoked,
DateTimeOffset hardDeadline,
DateTimeOffset? drainDeadline,
DateTimeOffset idleDeadline)
{
if (revoked)
{
return AccessRevoked;
}
if (now >= hardDeadline)
{
return new Ending(
RelayCloseReason.DurationLimit,
RelayProtocol.DurationLimitCloseCode,
"This relay session reached its maximum lifetime.");
}
if (drainDeadline is { } drain && now >= drain)
{
return ShuttingDown;
}
return now >= idleDeadline
? new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.")
: null;
}
private static Ending AccessRevoked { get; } = new(
RelayCloseReason.AccessRevoked,
RelayProtocol.AccessRevokedCloseCode,
"You no longer have access to this host.");
private static Ending ClientGone { get; } = private static Ending ClientGone { get; } =
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty); new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
@@ -91,4 +91,14 @@ internal static partial class RelayLog
Level = LogLevel.Warning, Level = LogLevel.Warning,
Message = "Relay session {SessionId} ended but its audit record could not be closed.")] Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception); internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
/// <remarks>
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
/// existed, and each still ends at its idle timeout or its lifetime.
/// </remarks>
[LoggerMessage(
EventId = 2309,
Level = LogLevel.Warning,
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, Exception exception);
} }
@@ -22,6 +22,7 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
{ {
private readonly Lock gate = new(); private readonly Lock gate = new();
private readonly Dictionary<Guid, int> perUser = []; private readonly Dictionary<Guid, int> perUser = [];
private readonly HashSet<Lease> open = [];
private int total; private int total;
/// <summary>How many sessions are open on this node.</summary> /// <summary>How many sessions are open on this node.</summary>
@@ -55,7 +56,16 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
/// <summary>Admits a session, or answers null when a limit is met.</summary> /// <summary>Admits a session, or answers null when a limit is met.</summary>
/// <returns>A lease that gives the place back when disposed.</returns> /// <returns>A lease that gives the place back when disposed.</returns>
internal Lease? TryAdmit(Guid userId) /// <summary>The sessions a user has open now, for re-checking their access.</summary>
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
{
lock (gate)
{
return [.. open.Where(lease => lease.UserId == userId)];
}
}
internal Lease? TryAdmit(Guid userId, Guid vaultId)
{ {
var settings = options.Value; var settings = options.Value;
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
perUser[userId] = held + 1; perUser[userId] = held + 1;
total++; total++;
return new Lease(this, userId); var lease = new Lease(this, userId, vaultId);
open.Add(lease);
return lease;
} }
} }
private void Release(Guid userId) private void Release(Lease lease, Guid userId)
{ {
lock (gate) lock (gate)
{ {
open.Remove(lease);
total--; total--;
// Removed at zero rather than left behind, so the dictionary is the size of the users with a // Removed at zero rather than left behind, so the dictionary is the size of the users with a
@@ -95,16 +109,41 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
} }
/// <summary>One admitted session's place. Idempotent to dispose.</summary> /// <summary>One admitted session's place. Idempotent to dispose.</summary>
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
{ {
private readonly CancellationTokenSource revoked = new();
private int released; private int released;
/// <summary>Whose session this is.</summary>
internal Guid UserId { get; } = userId;
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
internal Guid VaultId { get; } = vaultId;
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
internal CancellationToken Revoked => revoked.Token;
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
internal void Revoke()
{
try
{
revoked.Cancel();
}
catch (ObjectDisposedException)
{
// Already over.
}
}
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
{ {
if (Interlocked.Exchange(ref released, 1) == 0) if (Interlocked.Exchange(ref released, 1) == 0)
{ {
registry.Release(userId); registry.Release(this, UserId);
revoked.Dispose();
} }
} }
} }
+12
View File
@@ -27,4 +27,16 @@ internal static partial class SyncLog
int appliedCount, int appliedCount,
int operationCount, int operationCount,
Guid vaultId); Guid vaultId);
[LoggerMessage(
EventId = 2003,
Level = LogLevel.Information,
Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")]
internal static partial void Collected(ILogger logger, int count);
[LoggerMessage(
EventId = 2004,
Level = LogLevel.Warning,
Message = "Tombstone collection failed; it will run again at the next interval.")]
internal static partial void CollectionFailed(ILogger logger, Exception exception);
} }
+88 -23
View File
@@ -26,12 +26,59 @@ internal sealed class SyncService(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var afterSequence = 0L; var afterSequence = 0L;
var issuedUnder = 0L;
if (!string.IsNullOrEmpty(request.Cursor) if (!string.IsNullOrEmpty(request.Cursor)
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence)) && !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
{ {
throw new InvalidCursorException(); throw new InvalidCursorException();
} }
// Read before the changes, for the cursor this read hands out; see the check after it.
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
.ConfigureAwait(false);
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
//
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
// a read from the beginning that started after the collection, and its early pages are behind the
// horizon because old, untouched items are; refusing those would send that read back to the start
// for ever. See SyncCursor.
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
{
throw new InvalidCursorException();
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
// The horizon from before the read, never the one after: a collection landing between the two
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
// a restart.
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
}
/// <summary>The changes after a position, one page of them, and whether there are more.</summary>
private async Task<(List<VaultChange> Changes, bool HasMore)> ReadPageAsync(
Guid vaultId,
long afterSequence,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var limit = Math.Clamp( var limit = Math.Clamp(
request.Limit ?? options.DefaultPullLimit, request.Limit ?? options.DefaultPullLimit,
1, 1,
@@ -43,7 +90,7 @@ internal sealed class SyncService(
// One extra row, so "is there more" needs no second query. // One extra row, so "is there more" needs no second query.
var query = database.VaultChanges var query = database.VaultChanges
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence); .Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
if (types is not null) if (types is not null)
{ {
@@ -62,18 +109,7 @@ internal sealed class SyncService(
changes.RemoveAt(changes.Count - 1); changes.RemoveAt(changes.Count - 1);
} }
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false); return (changes, hasMore);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
} }
/// <summary> /// <summary>
@@ -100,9 +136,7 @@ internal sealed class SyncService(
{ {
ValidateBatchLimits(request); ValidateBatchLimits(request);
var strategy = database.Database.CreateExecutionStrategy(); return await database.ExecuteFreshAsync([vault], async () =>
return await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -536,9 +570,21 @@ internal sealed class SyncService(
/// <summary>Attaches current row state to change-log entries.</summary> /// <summary>Attaches current row state to change-log entries.</summary>
/// <remarks> /// <remarks>
/// <para>
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per /// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull /// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from. /// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
/// </para>
/// <para>
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
/// </para>
/// </remarks> /// </remarks>
private async Task<List<Contracts.SyncChange>> HydrateAsync( private async Task<List<Contracts.SyncChange>> HydrateAsync(
Vault vault, Vault vault,
@@ -571,10 +617,13 @@ internal sealed class SyncService(
} }
} }
return [.. changes.Select(change => Describe( return [.. changes
change, .Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
kinds.GetValueOrDefault(change.EntityType), || row.ChangeSequence <= change.Sequence)
rows.GetValueOrDefault((change.EntityType, change.EntityId))))]; .Select(change => Describe(
change,
kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
} }
/// <summary>Turns one change-log entry and its row into what a client receives.</summary> /// <summary>Turns one change-log entry and its row into what a client receives.</summary>
@@ -609,14 +658,30 @@ internal sealed class SyncService(
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary> /// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type)); private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) => /// <remarks>
await database.VaultChanges /// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
/// cost a full resync for nothing.
/// </remarks>
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var head = await database.VaultChanges
.Where(c => c.VaultId == vaultId) .Where(c => c.VaultId == vaultId)
.OrderByDescending(c => c.Sequence) .OrderByDescending(c => c.Sequence)
.Select(c => c.Sequence) .Select(c => c.Sequence)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
}
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Vaults
.Where(v => v.Id == vaultId)
.Select(v => v.SyncHorizon)
.SingleAsync(cancellationToken);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) => private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new( new(
operation.OperationId, operation.OperationId,
@@ -0,0 +1,198 @@
using DodoSSH.Api.Setup;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Sync;
/// <summary>
/// Removes tombstones past their retention, and change-log entries a later one has superseded.
/// </summary>
/// <remarks>
/// <para>
/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a
/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every
/// edit ever made to read the items that exist.
/// </para>
/// <para>
/// <b>Superseded entries go at any time.</b> A pull returns entries after a cursor in order, so a client
/// before both an item's old entry and its new one reads the new one, and a client between them already has
/// the old one. Nothing reads history: a conflict resolves against the newest entry alone.
/// </para>
/// <para>
/// <b>Tombstones go after the retention</b>, and taking them is the one change here a client can notice: a
/// machine that last synced before the deletion would never hear of it. So each vault records the highest
/// sequence collected from it — <see cref="Vault.SyncHorizon"/> — and a pull from a cursor below that is
/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer
/// has; see the client's <c>SyncEngine</c>.
/// </para>
/// <para>
/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to
/// the vault — and the items are read again under it, so one restored since it was found is left alone.
/// </para>
/// </remarks>
internal sealed class TombstoneCollector(
DodoDbContext database,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollector> logger)
{
/// <summary>Collects what is due, once.</summary>
/// <returns>How many tombstones and superseded entries went.</returns>
internal async Task<int> RunOnceAsync(CancellationToken cancellationToken)
{
var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays);
var collected = 0;
collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false);
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
SyncLog.Collected(logger, collected);
return collected;
}
private async Task<int> CollectAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var due = await table(database)
.Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before)
.Select(item => new { item.VaultId, item.Id })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var collected = 0;
foreach (var vault in due.GroupBy(item => item.VaultId))
{
var ids = vault.Select(item => item.Id).ToArray();
collected += await database.ExecuteFreshAsync(
[],
() => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken))
.ConfigureAwait(false);
}
return collected;
}
private async Task<int> CollectFromVaultAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
Guid vaultId,
Guid[] ids,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
await database.Database
.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken)
.ConfigureAwait(false);
// Read again under the lock: an item restored since it was found is no longer due.
var still = await table(database)
.Where(item => item.VaultId == vaultId
&& ids.Contains(item.Id)
&& item.DeletedAtUtc != null
&& item.DeletedAtUtc < before)
.Select(item => new { item.Id, item.ChangeSequence })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (still.Count == 0)
{
return 0;
}
var going = still.Select(item => item.Id).ToArray();
var horizon = still.Max(item => item.ChangeSequence);
await table(database)
.Where(item => item.VaultId == vaultId && going.Contains(item.Id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.VaultChanges
.Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.Vaults
.Where(v => v.Id == vaultId && v.SyncHorizon < horizon)
.ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken)
.ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return still.Count;
}
/// <summary>Removes every change-log entry a later entry for the same item has superseded.</summary>
private Task<int> CompactAsync(CancellationToken cancellationToken) =>
database.Database.ExecuteSqlRawAsync(
"DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older "
+ "USING " + DodoDbContext.SchemaName + ".sync_change AS newer "
+ "WHERE newer.vault_id = older.vault_id "
+ "AND newer.entity_type = older.entity_type "
+ "AND newer.entity_id = older.entity_id "
+ "AND newer.sequence > older.sequence",
cancellationToken);
}
/// <summary>Runs the <see cref="TombstoneCollector"/> on a schedule, in a scope of its own each time.</summary>
internal sealed class TombstoneCollectionService(
IServiceScopeFactory scopes,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollectionService> logger) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false);
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>()
.RunOnceAsync(stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The next run tries again; nothing collected late is harmed by waiting.
SyncLog.CollectionFailed(logger, exception);
}
}
}
}
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
} }
// Owner, not admin. An admin the owner promoted must not be able to archive the team out // Owner, not admin. An admin the owner promoted must not be able to archive the team out
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this // from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
// re-checks it, unlike the transfer below, so this line is the whole of the guard. // checks it again, because deleting a team's last vault archives it too.
if (!access.IsOwner) if (!access.IsOwner)
{ {
return Problems.Coded( return Problems.Coded(
@@ -0,0 +1,27 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Serialises the writes that decide what a team owns, for the life of the transaction.
/// </summary>
/// <remarks>
/// <para>
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
/// </para>
/// <para>
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
/// </para>
/// </remarks>
internal static class TeamLock
{
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
database.Database.ExecuteSqlAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
cancellationToken);
}
+22 -16
View File
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Domain; using DodoSSH.Domain;
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
@@ -244,28 +245,37 @@ internal sealed class TeamService(
Team team, Team team,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(actor);
ArgumentNullException.ThrowIfNull(team); ArgumentNullException.ThrowIfNull(team);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false); // Both callers check this first and answer the caller properly; this is what stops a third one that
// forgets from letting an admin archive the team out from under its owner.
if (vaultCount > 0) if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
{ {
throw new TeamNotEmptyException( throw new InvalidOperationException("Only the owner of a team can archive it.");
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
} }
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy(); var archived = await database.ExecuteFreshAsync([team], async () =>
var archived = await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false); await using var _ = transaction.ConfigureAwait(false);
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
{
throw new TeamNotEmptyException(
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
}
var memberships = await database.TeamMemberships var memberships = await database.TeamMemberships
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null) .Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
@@ -335,9 +345,7 @@ internal sealed class TeamService(
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken) var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy(); await database.ExecuteFreshAsync([incoming, outgoing], async () =>
await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -716,9 +724,7 @@ internal sealed class TeamService(
} }
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy(); var revoked = await database.ExecuteFreshAsync([membership], async () =>
var revoked = await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
{ {
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false); var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
if (orphaned is not null) // Archived only by its owner. Archiving revokes every membership, the owner's included, and an
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
// the owner, who can archive it or make a vault in it.
if (orphaned is not null
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
{ {
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false); await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
} }
@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Domain; using DodoSSH.Domain;
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
"That vault id is already in use. Generate a new UUIDv7 and retry."); "That vault id is already in use. Generate a new UUIDv7 and retry.");
} }
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow()); await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id); TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
RekeyRequired: false); RekeyRequired: false);
} }
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
private Task WriteVaultAsync(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
string name,
UserKey key,
CancellationToken cancellationToken) =>
database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
});
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
{
var live = await database.Teams
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (!live)
{
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
}
}
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary> /// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
private void AddVaultWithSelfGrant( private void AddVaultWithSelfGrant(
UserAccount user, UserAccount user,
+10 -6
View File
@@ -41,13 +41,16 @@ builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>(); builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>(); builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice // A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the // whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
// write paths that announce a change need only the two methods that announce one, and should not gain // that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
// a reference to connection management to get them. // vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
builder.Services.AddSingleton<VaultEventHub>(); builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>( builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
provider => provider.GetRequiredService<VaultEventHub>());
// ADR 0003's tombstone collection, and the compaction of the change log that goes with it.
builder.Services.AddScoped<TombstoneCollector>();
builder.Services.AddHostedService<TombstoneCollectionService>();
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004. // The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
builder.Services.AddDodoRelay(); builder.Services.AddDodoRelay();
@@ -55,6 +58,7 @@ builder.Services.AddDodoRelay();
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's // Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
// DbContext, and a singleton would capture one for the lifetime of the process. // DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>(); builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
// Replaces the framework default, which is also registered as a singleton. Last registration wins. // Replaces the framework default, which is also registered as a singleton. Last registration wins.
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>(); builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
+9 -2
View File
@@ -62,7 +62,14 @@ internal static class Auth
private static void AddDodoPolicies(AuthorizationOptions options) private static void AddDodoPolicies(AuthorizationOptions options)
{ {
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser()); // Both policies also require the account to be active. A suspended or deprovisioned account still
// holds a token the identity provider will vouch for until it expires — the status is this server's
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
options.AddPolicy(
AuthenticatedPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.ActiveAccountRequirement()));
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler. // Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying // An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
@@ -72,7 +79,7 @@ internal static class Auth
EnrolledPolicy, EnrolledPolicy,
policy => policy policy => policy
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement())); .AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller. // Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy); options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
+4
View File
@@ -289,6 +289,10 @@ public sealed class SyncOptions
[Range(1, 3650)] [Range(1, 3650)]
public int TombstoneRetentionDays { get; set; } = 90; public int TombstoneRetentionDays { get; set; } = 90;
/// <summary>How often the tombstone collector runs, in minutes.</summary>
[Range(1, 7 * 24 * 60)]
public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60;
/// <summary> /// <summary>
/// Key used to sign sync cursors, base64. /// Key used to sign sync cursors, base64.
/// </summary> /// </summary>
+72
View File
@@ -0,0 +1,72 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Setup;
/// <summary>
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
/// rather than from what the failed attempt left in the change tracker.
/// </summary>
/// <remarks>
/// <para>
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
/// its two role changes, each reporting success.
/// </para>
/// <para>
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
/// before: nothing is cleared or reloaded until something has failed.
/// </para>
/// </remarks>
internal static class FreshAttempts
{
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
/// <param name="database">The request's context.</param>
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
/// <param name="attempt">The transaction, which begins and commits its own.</param>
internal static Task<T> ExecuteFreshAsync<T>(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task<T>> attempt)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(carried);
ArgumentNullException.ThrowIfNull(attempt);
var strategy = database.Database.CreateExecutionStrategy();
var attempts = 0;
return strategy.ExecuteAsync(async () =>
{
if (attempts++ > 0)
{
database.ChangeTracker.Clear();
foreach (var entity in carried)
{
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
}
}
return await attempt().ConfigureAwait(false);
});
}
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
internal static Task ExecuteFreshAsync(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task> attempt) =>
database.ExecuteFreshAsync(
carried,
async () =>
{
await attempt().ConfigureAwait(false);
return true;
});
}
+10 -5
View File
@@ -1,5 +1,6 @@
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Npgsql; using Npgsql;
@@ -29,14 +30,18 @@ internal static class Persistence
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"ConnectionStrings:{ConnectionStringName} is not configured."); $"ConnectionStrings:{ConnectionStringName} is not configured.");
services.AddDbContext<DodoDbContext>(options => options services.AddDbContext<DodoDbContext>((provider, options) => options
.UseNpgsql(connectionString, npgsql => npgsql .UseNpgsql(connectionString, npgsql => npgsql
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName) .MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
// Retry on transient faults only. Note this is safe here because writes go // Retry on transient faults only. A write transaction replayed by the strategy runs on
// through explicitly-managed transactions rather than relying on the execution // the same context, which still holds what the failed attempt tracked — every such
// strategy to replay ambient ones. // transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
// the database instead.
.EnableRetryOnFailure(3)) .EnableRetryOnFailure(3))
.UseSnakeCaseNamingConvention()); .UseSnakeCaseNamingConvention()
// None in production. The seam a test uses to make a commit fail the way a dropped
// connection would, which is the only way to watch a retry happen.
.AddInterceptors(provider.GetServices<IInterceptor>()));
services.AddHealthChecks() services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>( .AddCheck<DatabaseHealthCheck>(
+1
View File
@@ -24,6 +24,7 @@ internal static class RelaySetup
// the network seam holds no state. // the network seam holds no state.
services.AddSingleton<RelayTicketCodec>(); services.AddSingleton<RelayTicketCodec>();
services.AddSingleton<RelaySessionRegistry>(); services.AddSingleton<RelaySessionRegistry>();
services.AddSingleton<RelayAccessWatch>();
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>(); services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
// The application name isolates this deployment's payloads from any other ASP.NET application // The application name isolates this deployment's payloads from any other ASP.NET application
+5 -31
View File
@@ -48,6 +48,10 @@ internal sealed partial class DodoSshApp : Application
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Recorded, not handled: see CrashLog for why the process still goes.
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
ListenForOpenedLinks(); ListenForOpenedLinks();
ShowSomethingWhileComposing(desktop); ShowSomethingWhileComposing(desktop);
} }
@@ -352,39 +356,9 @@ internal sealed partial class DodoSshApp : Application
copyToClipboard: ClipboardWriter(desktop), copyToClipboard: ClipboardWriter(desktop),
readClipboard: ClipboardReader(desktop), readClipboard: ClipboardReader(desktop),
updates: updates, updates: updates,
openInEditor: OpenInDefaultEditorAsync, openInEditor: LocalEditor.OpenAsync,
watchFile: WatchFile); watchFile: WatchFile);
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
return Task.CompletedTask;
}
/// <summary> /// <summary>
/// Watches one file for saves, until the handle is disposed. /// Watches one file for saves, until the handle is disposed.
/// </summary> /// </summary>
@@ -0,0 +1,85 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Writes down why the application died, in the profile, before it does.
/// </summary>
/// <remarks>
/// <para>
/// An exception nothing caught used to close the window and leave nothing behind — no file, no event, no
/// sentence — so the only report anybody could make was "it closed". This does not keep the process alive:
/// an exception that reached the top is one no code was written to recover from, and carrying on in whatever
/// state it left would be a guess. What it adds is the record.
/// </para>
/// <para>
/// Bounded, and appended to rather than rewritten, so a run of crashes keeps its history without the file
/// growing for ever. Every write is best-effort: a profile that cannot be written to is a reason to lose the
/// log, never a reason to fail harder.
/// </para>
/// </remarks>
internal static class CrashLog
{
/// <summary>Past this, the next entry starts a fresh file.</summary>
private const long MaxBytes = 1024 * 1024;
private static readonly Lock Gate = new();
private static string? file;
/// <summary>Starts recording, for the process's two last-chance hooks.</summary>
/// <remarks>
/// The UI thread's own hook is added by the application once Avalonia is running — see
/// <see cref="RecordFromTheUiThread"/> — because the dispatcher must not be touched before the platform
/// has set it up.
/// </remarks>
internal static void Install(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
file = paths.CrashLogFile;
AppDomain.CurrentDomain.UnhandledException += (_, e) => Write("Unhandled", e.ExceptionObject as Exception);
// A fire-and-forget task that faulted. It does not end the process, which is exactly why it needs
// writing down: the work it was doing simply never happened, and nothing else would ever say so.
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Write("Unobserved task", e.Exception);
e.SetObserved();
};
}
/// <summary>The UI thread's hook: records the exception and lets it take the process as before.</summary>
internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
/// <summary>Appends one entry; never throws.</summary>
internal static void Write(string where, Exception? exception)
{
if (file is not { } path)
{
return;
}
var entry = $"{TimeProvider.System.GetUtcNow():O} {where}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
lock (Gate)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path) && new FileInfo(path).Length > MaxBytes)
{
File.Delete(path);
}
File.AppendAllText(path, entry);
}
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
{
// Best-effort, by design: see the type's remarks.
}
}
}
}
@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// What a platform key store calls the hardware key behind one profile's device unlock.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>One key per profile, because the key outlives the file and is shared by name.</b> The CNG key and
/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
/// profile directory. While every profile used the same name, the release build and the nightly — which
/// <see cref="ClientPaths.ForChannel"/> keeps apart precisely so neither can damage the other — shared one
/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
/// </para>
/// <para>
/// <b>The default profile keeps the name it has always had</b>, so an existing install's sealed key still
/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
/// than the directory's own name, because a portable copy can live in any folder and two such folders can
/// end in the same name.
/// </para>
/// </remarks>
internal static class DeviceKeyNames
{
/// <summary>The name of the key behind <paramref name="paths"/>' device unlock.</summary>
/// <param name="paths">The profile.</param>
/// <param name="legacy">The name every profile used before names were per profile.</param>
internal static string For(ClientPaths paths, string legacy)
{
ArgumentNullException.ThrowIfNull(paths);
var directory = Normalise(paths.DataDirectory);
if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
{
return legacy;
}
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
}
private static string Normalise(string directory) =>
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
}
@@ -0,0 +1,126 @@
using System.Diagnostics;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Opens a local copy of a remote file for editing, without running it.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
/// shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch Services binding
/// depending on which desktop this is. The shell decides <em>when</em>; see
/// <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// ◆ <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
/// which is the default text editor whatever the type. Everything else still goes to its association, which
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
/// </para>
/// <para>
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
/// by every mainstream desktop's file associations in an editor, not run.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
internal static class LocalEditor
{
/// <summary>
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
/// </summary>
/// <remarks>
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
/// items, and the script hosts that installers do not register there.
/// </remarks>
private static readonly string[] WindowsRunsThese =
[
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
];
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
private static readonly string[] MacRunsThese =
[
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
".tool", ".webloc", ".workflow",
];
/// <summary>Opens <paramref name="path"/> for editing.</summary>
/// <exception cref="InvalidOperationException">
/// This desktop would run the file and has no editor to offer in its place.
/// </exception>
internal static Task OpenAsync(string path)
{
ArgumentNullException.ThrowIfNull(path);
using var opened = Process.Start(StartInfoFor(path));
return Task.CompletedTask;
}
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
internal static ProcessStartInfo StartInfoFor(string path)
{
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
{
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
{
return edit;
}
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
}
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
{
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
}
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
{
throw new InvalidOperationException(
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
+ "Download it and open it in an editor instead.");
}
return new ProcessStartInfo(path) { UseShellExecute = true };
}
/// <summary>Whether Windows' default action for this file is to run it.</summary>
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
{
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
}
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
/// <remarks>
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
/// <c>run.bat</c> by the time anything acts on it.
/// </remarks>
private static bool HasExtension(string path, IEnumerable<string> extensions)
{
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
return extension.Length > 0
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
}
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths; private readonly ClientPaths paths;
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
/// <remarks>
/// <see cref="KeyTag"/> for the default profile and a tag of its own for every other, so that signing
/// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
/// registered before this is still sealed to the shared tag and is read from there until it registers
/// again; it is never deleted from a profile that does not own it.
/// </remarks>
private readonly string keyTag;
/// <summary>Creates the store.</summary> /// <summary>Creates the store.</summary>
public MacDeviceKeyStore(ClientPaths paths) public MacDeviceKeyStore(ClientPaths paths)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
this.paths = paths; this.paths = paths;
keyTag = DeviceKeyNames.For(paths, KeyTag);
} }
/// <summary> /// <summary>
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> devicePrivateKey, ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var sealedKey = Seal(devicePrivateKey.Span) var sealedKey = Seal(devicePrivateKey.Span, keyTag)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one."); "The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken) var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return Unseal(sealedKey); // The shared tag only while this profile has no key of its own, so one unlock is one prompt.
return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here, // Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
// and the file deleted above is the half that decides whether unlock will try at all. A key // and the file deleted above is the half that decides whether unlock will try at all. A key
// left in the enclave with no ciphertext to open is inert. // left in the enclave with no ciphertext to open is inert.
_ = DeleteKey(symbols, KeyTag); _ = DeleteKey(symbols, keyTag);
} }
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of /// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
/// them is the same — do not offer a device unlock. /// them is the same — do not offer a device unlock.
/// </remarks> /// </remarks>
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey) /// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
private static bool HasKey(string tag)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return false;
}
}
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey, string tag)
{ {
try try
{ {
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Created on first use rather than at registration, so that a device key re-registered after // Created on first use rather than at registration, so that a device key re-registered after
// a ForgetAsync gets a key again without anything having to notice that it had gone. // a ForgetAsync gets a key again without anything having to notice that it had gone.
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null); var privateKey = FindKey(scope, symbols, tag, prompt: null);
if (privateKey == IntPtr.Zero) if (privateKey == IntPtr.Zero)
{ {
privateKey = CreateEnclaveKey(scope, symbols, KeyTag); privateKey = CreateEnclaveKey(scope, symbols, tag);
} }
if (privateKey == IntPtr.Zero) if (privateKey == IntPtr.Zero)
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// on a thread that is also expected to draw the window behind it. /// on a thread that is also expected to draw the window behind it.
/// </para> /// </para>
/// </remarks> /// </remarks>
private static byte[]? Unseal(byte[] sealedKey) private static byte[]? Unseal(byte[] sealedKey, string tag)
{ {
try try
{ {
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
return null; return null;
} }
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt); var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
if (privateKey == IntPtr.Zero) if (privateKey == IntPtr.Zero)
{ {
@@ -0,0 +1,62 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// The device key store this machine supports, chosen once and off the UI thread.
/// </summary>
/// <remarks>
/// <para>
/// Choosing a store means probing for it, and on Windows the probe is creating an RSA-2048 key inside the TPM
/// and deleting it again — see <see cref="WindowsDeviceKeyStore.IsSupported"/> — which is a slow device doing
/// real work. It used to run synchronously on the UI thread three times over: once to choose the store at
/// startup, again when the unlock card asked whether to offer the gesture, and again after every unlock when
/// the security page asked whether to offer registering. A machine does not gain or lose a TPM while this
/// process runs, so the answer is worked out once, on the thread pool, and every question after it is a
/// lookup.
/// </para>
/// <para>
/// Available means the choice landed on a real store, which is what the probe that chose it established.
/// Everything else waits for the choice and hands over to it — in practice it has long since finished,
/// since nothing reaches for the device key until somebody is at the unlock card.
/// </para>
/// </remarks>
internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
{
private readonly Task<IDeviceKeyStore> chosen;
/// <summary>Starts choosing at once, on the thread pool.</summary>
internal ProbedDeviceKeyStore(Func<IDeviceKeyStore> choose)
{
ArgumentNullException.ThrowIfNull(choose);
chosen = Task.Run(choose);
}
/// <summary>The store the probe settled on, for a test that needs to know which.</summary>
internal Task<IDeviceKeyStore> Chosen => chosen;
/// <inheritdoc />
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
/// <inheritdoc />
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask ForgetAsync(CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
}
}
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
/// </remarks> /// </remarks>
public static class DesktopDeviceKeyStores public static class DesktopDeviceKeyStores
{ {
/// <summary>The best store this machine supports.</summary> /// <summary>The best store this machine supports, chosen on the thread pool.</summary>
/// <remarks>
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
/// thread at startup.
/// </remarks>
/// <param name="paths">Where a stored key lives.</param> /// <param name="paths">Where a stored key lives.</param>
/// <param name="askForPin"> /// <param name="askForPin">
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform /// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
@@ -57,6 +62,13 @@ public static class DesktopDeviceKeyStores
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin); ArgumentNullException.ThrowIfNull(askForPin);
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
}
private static IDeviceKeyStore Choose(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()) if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{ {
return new WindowsDeviceKeyStore(paths); return new WindowsDeviceKeyStore(paths);
@@ -123,6 +135,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// rather than failing to open blobs written by a previous build. A device that cannot be opened /// rather than failing to open blobs written by a previous build. A device that cannot be opened
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice /// falls back to the passphrase, which is survivable — but silently, and a user would only notice
/// their gesture had stopped working. /// their gesture had stopped working.
/// <para>
/// This is the default profile's key, and the name every profile shared before keys were per profile;
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
/// </para>
/// </remarks> /// </remarks>
private const string KeyName = "DodoSSH.DeviceKey.v1"; private const string KeyName = "DodoSSH.DeviceKey.v1";
@@ -133,11 +149,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths; private readonly ClientPaths paths;
/// <summary>This profile's own key.</summary>
/// <remarks>
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
/// profile that does not own it, which is the half of this that matters.
/// </remarks>
private readonly string keyName;
/// <summary>Creates the store.</summary> /// <summary>Creates the store.</summary>
public WindowsDeviceKeyStore(ClientPaths paths) public WindowsDeviceKeyStore(ClientPaths paths)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
this.paths = paths; this.paths = paths;
keyName = DeviceKeyNames.For(paths, KeyName);
} }
/// <summary> /// <summary>
@@ -182,7 +209,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> devicePrivateKey, ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var key = OpenOrCreate(); using var key = OpenOrCreate(keyName);
using var rsa = new RSACng(key); using var rsa = new RSACng(key);
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256); var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
@@ -204,7 +231,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken) var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return Unseal(sealedKey); // The shared key only while this profile has none of its own: a profile registered before keys were
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
? KeyName
: keyName;
return Unseal(sealedKey, sealedTo);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -215,9 +248,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
File.Delete(paths.DeviceKeyFile); File.Delete(paths.DeviceKeyFile);
} }
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider))) // This profile's key and never the shared one, unless this is the profile that owns it. Another
// profile's device unlock may still be sealed to it.
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
{ {
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider)); using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
key.Delete(); key.Delete();
} }
@@ -231,16 +266,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// user and all have the same remedy, so none of them are worth telling apart here — see /// user and all have the same remedy, so none of them are worth telling apart here — see
/// <c>UnlockStatus.DeviceKeyUnavailable</c>. /// <c>UnlockStatus.DeviceKeyUnavailable</c>.
/// </remarks> /// </remarks>
private static byte[]? Unseal(byte[] sealedKey) private static byte[]? Unseal(byte[] sealedKey, string name)
{ {
try try
{ {
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider))) if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
{ {
return null; return null;
} }
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider)); using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
using var rsa = new RSACng(key); using var rsa = new RSACng(key);
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256); return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
@@ -256,18 +291,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and /// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
/// silently is the one behaviour this whole file exists to prevent. /// silently is the one behaviour this whole file exists to prevent.
/// </remarks> /// </remarks>
private static CngKey OpenOrCreate() private static CngKey OpenOrCreate(string name)
{ {
var provider = new CngProvider(PlatformProvider); var provider = new CngProvider(PlatformProvider);
if (CngKey.Exists(KeyName, provider)) if (CngKey.Exists(name, provider))
{ {
return CngKey.Open(KeyName, provider); return CngKey.Open(name, provider);
} }
return CngKey.Create( return CngKey.Create(
CngAlgorithm.Rsa, CngAlgorithm.Rsa,
KeyName, name,
new CngKeyCreationParameters new CngKeyCreationParameters
{ {
Provider = provider, Provider = provider,
+3
View File
@@ -75,6 +75,9 @@ internal static class Program
LaunchLink = link; LaunchLink = link;
// After the claim, so a second launch that only hands its link over writes nothing.
CrashLog.Install(paths);
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} }
@@ -0,0 +1,54 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Tells a drawn titlebar's double-click from a press that starts a drag.
/// </summary>
/// <remarks>
/// <para>
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
/// The two do not mix: <c>BeginMoveDrag</c> hands the press to the operating system's own move loop, which
/// eats the release — and a double tap is a gesture Avalonia assembles from presses <em>and</em> releases,
/// so the second click often never completed one and the window did not maximise. The convention it was
/// imitating is one nobody thinks about until it does not work.
/// </para>
/// <para>
/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
/// </para>
/// </remarks>
internal sealed class CaptionPresses
{
private ulong lastAt;
private Point lastWhere;
private bool waitingForASecond;
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
{
var settings = Application.Current?.PlatformSettings;
var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
var where = e.GetPosition(bar);
var second = e.ClickCount == 2
|| (waitingForASecond
&& e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
&& Math.Abs(where.X - lastWhere.X) <= size.Width
&& Math.Abs(where.Y - lastWhere.Y) <= size.Height);
// A double-click spends both presses: a third that follows quickly starts again rather than toggling
// the window straight back.
waitingForASecond = !second;
lastAt = e.Timestamp;
lastWhere = where;
return second;
}
}
@@ -59,7 +59,7 @@
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}" <Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" /> <Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
/// </remarks> /// </remarks>
internal sealed partial class ConfirmDeleteCard : UserControl internal sealed partial class ConfirmDeleteCard : UserControl
{ {
public ConfirmDeleteCard() => InitializeComponent(); public ConfirmDeleteCard()
{
InitializeComponent();
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
}
} }
@@ -4,7 +4,7 @@
Width="420" SizeToContent="Height" Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False" CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{StaticResource Surface}" Background="{StaticResource Panel}"
Title="DodoSSH"> Title="DodoSSH">
<!-- <!--
@@ -32,7 +32,7 @@
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" /> <TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap" <TextBlock Foreground="{StaticResource TextDim}" FontSize="12" TextWrapping="Wrap"
x:Name="Explanation" /> x:Name="Explanation" />
<!-- <!--
@@ -39,7 +39,10 @@ public sealed partial class DevicePinWindow : Window
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary> /// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
public DevicePinWindow() public DevicePinWindow()
{ {
AvaloniaXamlLoader.Load(this); // InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
// fields below, and with Load the first of them was null — so this window threw on construction and
// no Linux PIN prompt was ever shown.
InitializeComponent();
Accept.Click += OnAccept; Accept.Click += OnAccept;
Cancel.Click += OnCancel; Cancel.Click += OnCancel;
@@ -253,8 +253,20 @@
</Grid> </Grid>
</Border> </Border>
<ScrollViewer Grid.Row="1"> <!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8"> <ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel"> <DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8"> <Border Classes="importrow" CornerRadius="8">
@@ -290,8 +302,7 @@
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer>
</Grid> </Grid>
</Border> </Border>
@@ -45,7 +45,11 @@
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha 80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
variant of a surface; the pre-multiplied ones there are accent washes. variant of a surface; the pre-multiplied ones there are accent washes.
--> -->
<Border Background="#CC05050A"> <!--
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
that cannot be answered by clicking away should not be answerable by tabbing away either.
-->
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
<Panel> <Panel>
<!-- ============ UNKNOWN HOST KEY ============ --> <!-- ============ UNKNOWN HOST KEY ============ -->
@@ -83,7 +87,7 @@
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="TRUST AND CONNECT" <Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding TrustHostKeyCommand}" /> Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" /> <Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -119,7 +123,7 @@
is not an answer, because this half of the control has none to give. Compare the accent button is not an answer, because this half of the control has none to give. Compare the accent button
above, which connects. above, which connects.
--> -->
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left" <Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
Command="{Binding RejectHostKeyCommand}" /> Command="{Binding RejectHostKeyCommand}" />
</StackPanel> </StackPanel>
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostKeyCard : UserControl internal sealed partial class HostKeyCard : UserControl
{ {
public HostKeyCard() => InitializeComponent(); public HostKeyCard() => InitializeComponent();
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
/// <remarks>
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> gives about every confirmation
/// and with more force here: an Enter meant for whatever was focused before the card appeared must not
/// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
/// this one is raised over a terminal that may be holding it natively — see
/// <c>MainWindow.OnHostKeyDecision</c>.
/// </remarks>
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
} }
+35 -14
View File
@@ -230,9 +230,13 @@
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
who has found this box should know about the one that also connects on Enter. who has found this box should know about the one that also connects on Enter.
One way, and passed back by the code-behind once the typing pauses: every change to the filter
rebuilds the board's sections and their cards, and a name typed at speed used to do that once a
letter. The view model still filters the moment it is told. See HostsScreen.OnFilterTyped.
--> -->
<Border Grid.Row="1" Margin="26,0,26,16"> <Border Grid.Row="1" Margin="26,0,26,16">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10" <TextBox x:Name="HostFilter" Text="{Binding HostFilter, Mode=OneWay}" Height="40" CornerRadius="10"
FontFamily="{StaticResource MonoFont}" FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" /> PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border> </Border>
@@ -573,8 +577,9 @@
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}" SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set."> ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
<!-- Builds only the cards near the view; see VirtualizingWrapPanel. -->
<ListBox.ItemsPanel> <ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate> <ItemsPanelTemplate><views:VirtualizingWrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel> </ListBox.ItemsPanel>
<!-- <!--
@@ -683,7 +688,7 @@
TextTrimming never fires and the name would run on past the card's own TextTrimming never fires and the name would run on past the card's own
border instead of trimming. See the longer note this carried in v4. border instead of trimming. See the longer note this carried in v4.
--> -->
<Grid ColumnDefinitions="*,Auto,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17" <TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
FontWeight="Bold" Foreground="{StaticResource Text}" FontWeight="Bold" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
@@ -708,17 +713,27 @@
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}" <Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
Width="9" Height="9" /> Width="9" Height="9" />
</StackPanel> </StackPanel>
</Grid>
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0" <!--
"not synced" and its kin sit on the address line. Beside the name, where it
used to be, it took seventy of the card's pixels from the one thing a card
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
both drew as "k8s…". The address can give the room up — it trims, and the
tooltip and the drawer both carry it whole.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
Foreground="{StaticResource TextGhost}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
VerticalAlignment="Center"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" /> <TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border> </Border>
</Grid> </Grid>
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
Foreground="{StaticResource TextGhost}"
TextTrimming="CharacterEllipsis" />
<!-- <!--
Which vault this host is in, and only when there is more than one to be Which vault this host is in, and only when there is more than one to be
in. It decides who else can see the host and where an edit goes back to, in. It decides who else can see the host and where an edit goes back to,
@@ -734,8 +749,13 @@
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom" <Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
Margin="0,8,0,0"> Margin="0,8,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" <!--
VerticalAlignment="Bottom"> A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
measures its children with infinite width, so the tags' WrapPanel below
never wrapped and ran past the card's edge; the last child of a DockPanel
gets the width that is actually left, and wraps in it.
-->
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
<!-- <!--
The pin badge: how many folders are pinned on this host, reading The pin badge: how many folders are pinned on this host, reading
@@ -743,8 +763,8 @@
property's own remarks. Only when there is at least one; a badge reading property's own remarks. Only when there is at least one; a badge reading
zero would be a fact nobody asked for. zero would be a fact nobody asked for.
--> -->
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2" <Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
IsVisible="{Binding HasPins}"> Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
<StackPanel Orientation="Horizontal" Spacing="3"> <StackPanel Orientation="Horizontal" Spacing="3">
<TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}" <TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}"
FontSize="11" Foreground="{StaticResource TextFaint}" /> FontSize="11" Foreground="{StaticResource TextFaint}" />
@@ -767,12 +787,13 @@
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2" <Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
Margin="0,0,4,4"> Margin="0,0,4,4">
<TextBlock Text="{Binding}" FontSize="11" <TextBlock Text="{Binding}" FontSize="11"
Foreground="{StaticResource TextFaint}" /> Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</StackPanel> </DockPanel>
<!-- <!--
HostRowViewModel.LastConnectedText is filled from the connection log on HostRowViewModel.LastConnectedText is filled from the connection log on
@@ -106,6 +106,48 @@ internal sealed partial class HostsScreen : UserControl
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel); Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
WireTheBand(); WireTheBand();
HostFilter.TextChanged += OnFilterTyped;
}
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
private int typed;
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
/// <remarks>
/// <para>
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
/// </para>
/// <para>
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
/// change it makes to the box comes back here as the same text, which changes nothing.
/// </para>
/// </remarks>
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
private async Task PassTheFilterOnAsync(string text, int keystroke)
{
await Pause(FilterPause).ConfigureAwait(true);
// A later letter has its own wait, and it is the one that speaks.
if (keystroke != typed)
{
return;
}
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
{
vault.HostFilter = text;
}
} }
/// <summary>The band's own wiring, and the two keys that go with a set.</summary> /// <summary>The band's own wiring, and the two keys that go with a set.</summary>
@@ -122,6 +164,13 @@ internal sealed partial class HostsScreen : UserControl
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen. // touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
Board.KeyDown += OnGridKey; Board.KeyDown += OnGridKey;
// The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See
// OnCardSelectionChanged for why this is not simply a two-way binding.
Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged);
// Tunnelled, because a section's list answers Enter itself and nothing bubbles past it.
Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel);
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged // ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps // out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
// between sections and below the last one belong to the stack around them. Tunnelled for the reason // between sections and below the last one belong to the stack around them. Tunnelled for the reason
@@ -315,6 +364,16 @@ internal sealed partial class HostsScreen : UserControl
return; return;
} }
// An arrow with the board itself focused — which is where this screen puts the keyboard, see
// KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever
// reaches a section's list, since none of them has the keyboard until a card has been clicked.
if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board))
{
e.Handled = EnterTheCards(vault);
return;
}
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries) if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
{ {
var order = vault.HostBoardOrder.ToList(); var order = vault.HostBoardOrder.ToList();
@@ -347,6 +406,80 @@ internal sealed partial class HostsScreen : UserControl
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>. /// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
/// <remarks>
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
/// while cards are ticked, where the selection is not what the set is about.
/// </remarks>
private void OnCardEnter(object? sender, KeyEventArgs e)
{
if (e.Key is not Key.Enter
|| e.KeyModifiers != KeyModifiers.None
|| Vault is not { IsChoosingHosts: false } vault
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
}
vault.SelectedHost = row;
_ = vault.ConnectCommand.ExecuteAsync(null);
e.Handled = true;
}
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
/// <remarks>
/// <para>
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
/// right-click handler exists to prevent.
/// </para>
/// <para>
/// ◆ Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
/// </para>
/// </remarks>
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (Vault is not { } vault
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|| ReferenceEquals(row, vault.SelectedHost))
{
return;
}
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
}
anchor = row;
vault.SelectedHost = row;
}
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
private bool EnterTheCards(VaultViewModel vault)
{
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
if (card?.DataContext is not HostRowViewModel row)
{
return false;
}
vault.SelectedHost = row;
anchor = row;
return card.Focus(NavigationMethod.Directional);
}
private void OnBandPressed(object? sender, PointerPressedEventArgs e) private void OnBandPressed(object? sender, PointerPressedEventArgs e)
{ {
EndBand(); EndBand();
@@ -131,8 +131,20 @@
</Grid> </Grid>
</Border> </Border>
<ScrollViewer Grid.Row="1"> <!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8"> <ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel"> <DataTemplate x:DataType="vm:ImportRowViewModel">
<Border Classes="importrow" CornerRadius="8"> <Border Classes="importrow" CornerRadius="8">
@@ -161,8 +173,7 @@
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer>
</Grid> </Grid>
</Border> </Border>
@@ -256,7 +256,8 @@
</Border> </Border>
<!-- ============ The table ============ --> <!-- ============ The table ============ -->
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"> <Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"
Grid.IsSharedSizeScope="True">
<Border Grid.Row="0" Padding="16,0" Height="44" <Border Grid.Row="0" Padding="16,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
@@ -275,12 +276,24 @@
item this application stores carries a timestamp; see the file-level remark above and item this application stores carries a timestamp; see the file-level remark above and
docs/design-import-gaps.md. docs/design-import-gaps.md.
--> -->
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6" <!--
VAULT is sized to what is in it, and shared between this heading and every row so the two agree:
with one vault no row names one, and a fixed column was a heading over a stripe of nothing.
-->
<Grid Grid.Row="1" Margin="0,10,16,6"
IsVisible="{Binding HasVaultItems}"> IsVisible="{Binding HasVaultItems}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="1.1*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
<ColumnDefinition Width="1.2*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1" <TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" /> Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" /> <TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" /> <TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
Margin="0,0,16,0" IsVisible="{Binding ShowsVaultColumn}" />
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" /> <TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
</Grid> </Grid>
@@ -290,18 +303,31 @@
Margin="8,0,8,10"> Margin="8,0,8,10">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel"> <DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0"> <Grid Height="40" Margin="6,0,10,0">
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0" <Grid.ColumnDefinitions>
VerticalAlignment="Center"> <ColumnDefinition Width="2" />
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" <ColumnDefinition Width="3*" />
<ColumnDefinition Width="1.1*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
<ColumnDefinition Width="1.2*" />
</Grid.ColumnDefinitions>
<!--
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
columns beside it.
-->
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}" Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" /> VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium" <TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" /> FontWeight="Medium" Foreground="{StaticResource Text}"
</StackPanel> TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
</Grid>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5" <TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" /> Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" TextTrimming="CharacterEllipsis" />
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" Margin="0,0,16,0"
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}"> VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="10" /> <TextBlock Text="{Binding VaultBadge}" FontSize="10" />
</Border> </Border>
+14 -3
View File
@@ -383,7 +383,12 @@
not replayed. See MainWindow.axaml.cs. not replayed. See MainWindow.axaml.cs.
--> -->
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}" <TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
PlaceholderText="https://dodossh.example" /> PlaceholderText="https://dodossh.example">
<!-- Enter signs in, as it unlocks on the unlock card. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}" <Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
@@ -395,8 +400,14 @@
<TextBlock Classes="heading" Text="Choose a keychain passphrase" /> <TextBlock Classes="heading" Text="Choose a keychain passphrase" />
<TextBlock Classes="hint" <TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." /> Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" /> <TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" /> PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}" <Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
+165 -16
View File
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
private bool wasUnlocked; private bool wasUnlocked;
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
private ShellState? closedState;
private bool wasAskingAboutHostKey;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
{ {
ShellState.Locked => UnlockPane.PassphraseBox, ShellState.Locked => UnlockPane.PassphraseBox,
ShellState.NeedsServer => ServerUrlBox, ShellState.NeedsServer => ServerUrlBox,
ShellState.NeedsEnrollment => NewPassphraseBox,
_ => this, _ => this,
}; };
@@ -226,33 +232,96 @@ internal sealed partial class MainWindow : Window
size.Execute(null); size.Execute(null);
e.Handled = true; e.Handled = true;
} }
// A host-key decision before the palette, because it is drawn over the palette: Escape is CANCEL or
// CLOSE, both of which refuse — never the answer that connects. See HostKeyCard.SafeAnswer.
else if (e.Key == Key.Escape && viewModel.IsHostKeyDecisionShowing)
{
viewModel.Vault?.RejectHostKeyCommand.Execute(null);
e.Handled = true;
}
else if (viewModel.IsSearching) else if (viewModel.IsSearching)
{ {
Palette.HandleKey(e); Palette.HandleKey(e);
} }
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
// already follows one branch up. Checked after the palette rather than before it: the two states
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
// settings does not open the palette — but an Escape while both were somehow true should close the
// thing drawn on top, which is the palette.
//
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
// "Back to application" while IsImportOpen is true.
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
{ {
viewModel.CloseImportCommand.Execute(null);
e.Handled = true;
}
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
{
viewModel.LeaveSettingsCommand.Execute(null);
e.Handled = true; e.Handled = true;
} }
base.OnKeyDown(e); base.OnKeyDown(e);
} }
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
/// <remarks>
/// <para>
/// One rule throughout — closest thing first — so the order of these arms is the order things are drawn
/// over one another. A confirmation before anything, because it is a question in front of whatever page
/// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings.
/// </para>
/// <para>
/// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already
/// follows in the caller. Checked after the palette rather than before it: the two states are mutually
/// exclusive in practice — opening the palette does not enter settings mode and entering settings does
/// not open the palette — but an Escape while both were somehow true should close the thing drawn on
/// top, which is the palette.
/// </para>
/// <para>
/// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own
/// back button follows by showing "Back to preferences" rather than "Back to application" while
/// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so
/// it goes first and settings stays.
/// </para>
/// <para>
/// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its
/// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer.
/// </para>
/// </remarks>
private static bool CloseTheNearestThing(MainWindowViewModel viewModel)
{
if (AnswerNoToTheQuestionShowing(viewModel))
{
return true;
}
var close = viewModel switch
{
{ IsImportOpen: true } => viewModel.CloseImportCommand,
{ IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand,
{ IsSettingsMode: true } => viewModel.LeaveSettingsCommand,
{ IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault }
=> vault.CloseDrawerCommand,
_ => null,
};
close?.Execute(null);
return close is not null;
}
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
/// <remarks>
/// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a
/// question about destroying something. A deletion question left armed on a screen somebody navigated away
/// from is cancelled by it too, which is harmless — it is the same answer the question would get if it
/// were on screen — and cheaper than deciding which of the six places that card is drawn is showing.
/// </remarks>
private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel)
{
if (viewModel.IsConfirmingSignOut)
{
viewModel.CancelSignOutCommand.Execute(null);
return true;
}
if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault })
{
vault.CancelDeleteCommand.Execute(null);
return true;
}
return false;
}
/// <summary> /// <summary>
/// The text-size chords, when the terminal is not the thing hearing them. /// The text-size chords, when the terminal is not the thing hearing them.
/// </summary> /// </summary>
@@ -300,6 +369,15 @@ internal sealed partial class MainWindow : Window
Terminal.Source = viewModel.TerminalPageUrl; Terminal.Source = viewModel.TerminalPageUrl;
wasUnlocked = viewModel.IsUnlocked; wasUnlocked = viewModel.IsUnlocked;
closedState = null;
wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing;
// The shell may have reached its card before this window was given it, and then no change would
// ever say so.
if (!viewModel.IsUnlocked)
{
FocusClosedVaultCard(viewModel);
}
viewModel.TerminalSessionOpened += OnTerminalSessionOpened; viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.TerminalFocusRequested += OnTerminalFocusRequested; viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
@@ -358,6 +436,10 @@ internal sealed partial class MainWindow : Window
OnSelectedTabChanged(viewModel); OnSelectedTabChanged(viewModel);
break; break;
case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
OnHostKeyDecision(viewModel);
break;
default: default:
break; break;
} }
@@ -373,10 +455,77 @@ internal sealed partial class MainWindow : Window
{ {
ReleaseKeyboardTo(ClosedVaultKeyboardHome); ReleaseKeyboardTo(ClosedVaultKeyboardHome);
} }
else if (!unlocked && viewModel.State != closedState)
{
FocusClosedVaultCard(viewModel);
}
closedState = unlocked ? null : viewModel.State;
wasUnlocked = unlocked; wasUnlocked = unlocked;
} }
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
/// <remarks>
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
/// raised for every state change, and the state does not change while they type. Posted, because the card
/// is revealed by a binding the same change has not reached yet.
/// </remarks>
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
{
closedState = viewModel.State;
var home = ClosedVaultKeyboardHome;
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
}
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
/// <remarks>
/// <para>
/// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose
/// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and
/// Tab walked the page under it. Hence <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
/// posted, because the binding that reveals the card has not run yet.
/// </para>
/// <para>
/// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must
/// move once per question, not once per raise. On the way out the keyboard goes where the window would
/// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens.
/// </para>
/// </remarks>
private void OnHostKeyDecision(MainWindowViewModel viewModel)
{
var asking = viewModel.IsHostKeyDecisionShowing;
if (asking == wasAskingAboutHostKey)
{
return;
}
wasAskingAboutHostKey = asking;
Dispatcher.UIThread.Post(
() =>
{
if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking)
{
return;
}
if (asking)
{
ReleaseKeyboardTo(HostKeyPane.SafeAnswer);
}
else
{
OnShowingSomethingElse(viewModel);
}
},
DispatcherPriority.Loaded);
}
/// <remarks> /// <remarks>
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected /// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
/// to start typing into immediately — but the palette does that for itself when it becomes visible, /// to start typing into immediately — but the palette does that for itself when it becomes visible,
@@ -0,0 +1,81 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Hands the keyboard to a confirmation's harmless answer when the question appears.
/// </summary>
/// <remarks>
/// <para>
/// For the questions drawn in place on a page — the deletion and the sign-out confirmations — which used to
/// appear with the keyboard still on the button that asked them, or nowhere. Tab then walked the page behind
/// the question, Enter did nothing useful, and somebody working from the keyboard had to reach for the mouse
/// to say no.
/// </para>
/// <para>
/// ◆ <b>The harmless answer, never the one the question is about.</b> Enter on a focused button presses it,
/// so the button that takes the keyboard is the one an accidental Enter is allowed to press: CANCEL. Putting it
/// on DELETE or SIGN OUT AND DELETE would make two keystrokes — the one that asked and the one after — a way to
/// destroy something without reading the sentence in between.
/// </para>
/// <para>
/// Each card follows its own question rather than the window following all of them, because the same card is
/// drawn in several places and only the one somebody can see should take the keyboard — which the posted
/// check on <c>IsEffectivelyVisible</c> decides, after the binding that reveals it has run. Subscribed only
/// while the card is in the tree: settings pages are rebuilt as they are visited, and a card that subscribed
/// for good would leave one handler on the long-lived view model per visit.
/// </para>
/// </remarks>
internal static class SafeAnswerFocus
{
internal static void Follow(Control card, string question, Func<Control> safeAnswer)
{
INotifyPropertyChanged? watched = null;
void OnChanged(object? sender, PropertyChangedEventArgs e)
{
if (!string.Equals(e.PropertyName, question, StringComparison.Ordinal))
{
return;
}
Dispatcher.UIThread.Post(
() =>
{
if (safeAnswer() is { IsEffectivelyVisible: true } answer)
{
answer.Focus();
}
},
DispatcherPriority.Loaded);
}
void Watch(INotifyPropertyChanged? next)
{
if (watched is not null)
{
watched.PropertyChanged -= OnChanged;
}
watched = next;
if (watched is not null)
{
watched.PropertyChanged += OnChanged;
}
}
card.AttachedToVisualTree += (_, _) => Watch(card.DataContext as INotifyPropertyChanged);
card.DetachedFromVisualTree += (_, _) => Watch(null);
card.DataContextChanged += (_, _) =>
{
if (card.IsAttachedToVisualTree())
{
Watch(card.DataContext as INotifyPropertyChanged);
}
};
}
}
@@ -86,7 +86,14 @@
Classes.connecting="{Binding IsConnecting}" Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}" Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" /> <!--
Bounded. A label is whatever the host was called, or the address typed into the palette,
and one long one used to push every tab after it and the "+" past the strip's edge. The
whole of it is on the label's own tooltip; the tab's is the address, as before.
-->
<TextBlock Text="{Binding Label}" VerticalAlignment="Center"
MaxWidth="200" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Label}" />
<Button Classes="flat close inline" Width="16" Height="16" Padding="0" <Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -136,7 +136,7 @@
<Border Classes="settingscard"> <Border Classes="settingscard">
<StackPanel Margin="24,20" Spacing="10"> <StackPanel Margin="24,20" Spacing="10">
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." /> Text="Unlocking with this machine's device key moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." /> Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
@@ -46,19 +46,21 @@
<TextBlock Classes="settingstitle" Text="Security" /> <TextBlock Classes="settingstitle" Text="Security" />
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20"> <Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
<StackPanel Orientation="Horizontal" Spacing="16"> <!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F"> <Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
Margin="0,0,16,0" VerticalAlignment="Top">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE897;" FontSize="20" <TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE897;" FontSize="20"
Foreground="{StaticResource Live}" Foreground="{StaticResource Live}"
HorizontalAlignment="Center" VerticalAlignment="Center" /> HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border> </Border>
<StackPanel Spacing="6" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2" <TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
Foreground="{StaticResource Text}" /> Foreground="{StaticResource Text}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." /> Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
</StackPanel> </StackPanel>
</StackPanel> </Grid>
</Border> </Border>
<TextBlock Classes="settingssection" Text="UNLOCKING" /> <TextBlock Classes="settingssection" Text="UNLOCKING" />
@@ -68,15 +70,15 @@
<Border Classes="settingsrow last"> <Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640"> <StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" /> <TextBlock Classes="settingsrowtitle" Text="{Binding DeviceUnlock.Name}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." Text="{Binding DeviceUnlock.Offer}"
IsVisible="{Binding CanRegisterDevice}" /> IsVisible="{Binding CanRegisterDevice}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation." Text="{Binding DeviceUnlock.Registered}"
IsVisible="{Binding CanForgetDevice}" /> IsVisible="{Binding CanForgetDevice}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key." Text="{Binding DeviceUnlock.Unavailable}"
IsVisible="{Binding HasNoDeviceKeyOption}" /> IsVisible="{Binding HasNoDeviceKeyOption}" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER" <Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
@@ -81,11 +81,15 @@
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}"> IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel> <StackPanel>
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10"> <!--
No VAULT column, although the design draws one: TagRowViewModel does not carry which vault a tag
lives in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark), so
the column was a heading over nothing on every row.
-->
<Grid ColumnDefinitions="1.4*,2*,Auto" Margin="20,14,20,10">
<TextBlock Grid.Column="0" Classes="label" Text="TAG" /> <TextBlock Grid.Column="0" Classes="label" Text="TAG" />
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" /> <TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" /> <TextBlock Grid.Column="2" Classes="label" Text=" " />
<TextBlock Grid.Column="3" Classes="label" Text=" " />
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding Vault.Tags}"> <ItemsControl ItemsSource="{Binding Vault.Tags}">
@@ -97,7 +101,7 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagRowViewModel"> <DataTemplate x:DataType="vm:TagRowViewModel">
<Border Classes="settingsrow last" Padding="20,10"> <Border Classes="settingsrow last" Padding="20,10">
<Grid ColumnDefinitions="1.4*,*,*,Auto"> <Grid ColumnDefinitions="1.4*,2*,Auto">
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center"> <Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" /> <TextBlock Text="{Binding Label}" />
@@ -107,14 +111,7 @@
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding Description}" /> Text="{Binding Description}" />
<!-- <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
so there is no second vault this column could honestly name.
-->
<TextBlock Grid.Column="2" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="paneicon" Width="30" Height="30" FontSize="13" <Button Classes="paneicon" Width="30" Height="30" FontSize="13"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}" Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag."> CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
@@ -26,7 +26,7 @@
--> -->
<Border Height="53" Background="{StaticResource DeepChrome}" <Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised"> PointerPressed="OnDrag">
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0"> <Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
{ {
public SettingsTitleBar() => InitializeComponent(); public SettingsTitleBar() => InitializeComponent();
private readonly CaptionPresses presses = new();
private Window? Host => TopLevel.GetTopLevel(this) as Window; private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary> /// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
return; return;
} }
// Decided on the press, before the drag would take it: see CaptionPresses.
if (presses.IsSecond(e, this))
{
OnToggleMaximised(sender, e);
e.Handled = true;
return;
}
Host?.BeginMoveDrag(e); Host?.BeginMoveDrag(e);
} }
@@ -31,6 +42,7 @@ internal sealed partial class SettingsTitleBar : UserControl
} }
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary> /// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e) private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{ {
if (Host is not { } window) if (Host is not { } window)
@@ -55,7 +55,7 @@
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="SIGN OUT AND DELETE" <Button Classes="danger" Content="SIGN OUT AND DELETE"
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" /> <Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
</StackPanel> </StackPanel>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
@@ -1,9 +1,15 @@
using Avalonia.Controls; using Avalonia.Controls;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary> /// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
internal sealed partial class SignOutCard : UserControl internal sealed partial class SignOutCard : UserControl
{ {
public SignOutCard() => InitializeComponent(); public SignOutCard()
{
InitializeComponent();
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
}
} }
@@ -167,8 +167,12 @@
TextAlignment="Center" MaxWidth="360" TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" /> IsVisible="{Binding !HasVisible}" />
<!--
Only with a snippet chosen. Three greyed buttons under a list nobody has clicked in read as
something broken rather than something waiting, and the pane on the right already says what to do.
-->
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" <Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,1,0,0"> BorderThickness="0,1,0,0" IsVisible="{Binding HasSelection}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}" <Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" /> IsEnabled="{Binding HasSelection}" />
+1 -1
View File
@@ -32,7 +32,7 @@
--> -->
<Border Height="53" Background="{StaticResource DeepChrome}" <Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised"> PointerPressed="OnDrag">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0"> <Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
+13 -1
View File
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
} }
} }
private readonly CaptionPresses presses = new();
private Window? Host => TopLevel.GetTopLevel(this) as Window; private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <remarks> /// <remarks>
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
return; return;
} }
// Decided on the press, before the drag would take it: see CaptionPresses.
if (presses.IsSecond(e, this))
{
OnToggleMaximised(sender, e);
e.Handled = true;
return;
}
Host?.BeginMoveDrag(e); Host?.BeginMoveDrag(e);
} }
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
/// <remarks> /// <remarks>
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and /// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
/// the one people reach for without thinking about it. /// the one people reach for without thinking about it. The double-click comes from <see cref="OnDrag"/>
/// rather than <c>DoubleTapped</c>; see <see cref="CaptionPresses"/>.
/// </remarks> /// </remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e) private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{ {
@@ -1190,8 +1190,24 @@
Text="{Binding Status}" IsVisible="{Binding IsConnected}" /> Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid> </Grid>
<ScrollViewer Grid.Row="2"> <!--
<ItemsControl ItemsSource="{Binding Transfers}"> Virtualised: a folder download queues a row per file, and a stack panel inside an outer scroller
laid out and drew every one of them, thousands deep. The scroller is the control's own so the panel
knows the viewport it has to fill and realises only those rows.
-->
<ItemsControl Grid.Row="2" ItemsSource="{Binding Transfers}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel"> <DataTemplate x:DataType="vm:TransferRowViewModel">
<!-- <!--
@@ -1239,7 +1255,6 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer>
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0" <Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" /> HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
@@ -44,15 +44,15 @@
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<!-- <!--
Shown only when this machine has both a registered wrap and a keystore still willing to release the Shown only when this machine has both a registered wrap and a keystore still willing to release the
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never key. Absent rather than disabled, because a greyed-out "Use Touch ID" on a machine that never
had it invites the reading that something is broken — and the passphrase box beside it is not a had it invites the reading that something is broken — and the passphrase box beside it is not a
fallback, it is the ordinary way in. fallback, it is the ordinary way in.
--> -->
<Button Classes="ghost" Content="USE WINDOWS HELLO" <Button Classes="ghost" Content="{Binding DeviceUnlock.Button}"
Command="{Binding UnlockWithDeviceCommand}" Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}" IsVisible="{Binding CanUnlockWithDevice}"
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." /> ToolTip.Tip="{Binding DeviceUnlock.Tip}" />
</StackPanel> </StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -0,0 +1,383 @@
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// A wrap panel that builds only the items near the part of it on screen.
/// </summary>
/// <remarks>
/// <para>
/// The hosts board is a list per section, each laid out in rows of cards. It was a plain
/// <see cref="WrapPanel"/>, which builds, measures and draws every card on the board whether it is in view or
/// not — some twenty-five controls a card, so a keychain of three hundred hosts took seconds to open and to
/// redraw after a filter was cleared, and a thousand took several times that. Avalonia has no virtualising
/// wrap panel of its own; this is one, for items of a fixed width.
/// </para>
/// <para>
/// <b>Widths are the first item's; heights are per row.</b> A card's width is set by its style, so the
/// number of columns is arithmetic. Its height is not quite — a card wearing many tags grows a line — so each
/// row's height is the tallest card built in it, and a row not yet built is assumed to be as tall as the last
/// one measured. When a row turns out different the rows below it move, which is the ordinary price of
/// estimating, and is only ever seen while scrolling into rows nobody has looked at yet.
/// </para>
/// <para>
/// <b>What is on screen is read from the effective viewport</b>, which is the part of this panel the
/// scroller around the whole board shows — the section's own list never scrolls, so its own scroller has
/// nothing to say. A section scrolled out of view builds nothing at all past the one card measured for size.
/// </para>
/// <para>
/// Everything else a list does is left to the list. Containers come from its generator and are prepared by
/// it, so selection, the card template, the context menu and the band all work on the containers that exist.
/// A container holding the keyboard is never put away while it does, so arrowing past the edge of the view
/// does not drop the focus on the floor.
/// </para>
/// </remarks>
internal sealed class VirtualizingWrapPanel : VirtualizingPanel
{
/// <summary>How far past the visible part, above and below, items are built ahead of being scrolled to.</summary>
private const double Overscan = 240;
/// <summary>What a row is assumed to be before anything in the panel has been measured.</summary>
private const double FallbackRowHeight = 124;
private readonly Dictionary<int, Control> realized = [];
private readonly Dictionary<object, Stack<Control>> recycled = [];
/// <summary>The pool each container this panel made goes back to; items that are their own containers are not in it.</summary>
private readonly Dictionary<Control, object> poolOf = [];
private readonly Dictionary<int, double> rowHeights = [];
private Rect viewport;
private bool viewportKnown;
private Size itemSize;
private int columns = 1;
/// <summary>An item asked to be scrolled to, kept built until a measure has seen it in range.</summary>
private int? pinned;
public VirtualizingWrapPanel()
{
EffectiveViewportChanged += (_, e) =>
{
if (viewportKnown && e.EffectiveViewport == viewport)
{
return;
}
viewport = e.EffectiveViewport;
viewportKnown = true;
InvalidateMeasure();
};
}
/// <inheritdoc />
protected override Size MeasureOverride(Size availableSize)
{
var items = Items;
if (items.Count == 0)
{
RecycleAllExcept(static _ => false);
return default;
}
if (itemSize == default)
{
var first = RealizedOrNew(0);
first.Measure(Size.Infinity);
itemSize = first.DesiredSize;
}
var width = double.IsInfinity(availableSize.Width)
? itemSize.Width * items.Count
: availableSize.Width;
var nowColumns = Math.Max(1, (int)Math.Floor(width / Math.Max(1, itemSize.Width)));
if (nowColumns != columns)
{
columns = nowColumns;
rowHeights.Clear();
}
var tops = RowTops(items.Count);
var (firstIndex, lastIndex) = VisibleRange(tops, items.Count);
RecycleAllExcept(index =>
(index >= firstIndex && index <= lastIndex)
|| index == pinned
|| realized[index].IsKeyboardFocusWithin);
for (var index = firstIndex; index <= lastIndex; index++)
{
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
var row = index / columns;
rowHeights[row] = Math.Max(rowHeights.GetValueOrDefault(row), container.DesiredSize.Height);
}
if (pinned is { } kept && kept >= firstIndex && kept <= lastIndex)
{
pinned = null;
}
tops = RowTops(items.Count);
return new Size(Math.Min(width, columns * itemSize.Width), tops[^1]);
}
/// <inheritdoc />
protected override Size ArrangeOverride(Size finalSize)
{
var tops = RowTops(Items.Count);
foreach (var (index, container) in realized)
{
container.Arrange(ItemRect(index, tops));
}
return finalSize;
}
/// <inheritdoc />
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
{
base.OnItemsChanged(items, e);
// Every change is answered as a reset. The board swaps its lists in whole, so an incremental answer
// would be code for a case that does not happen; and indexes, row heights and the pinned item all
// describe a list that is no longer the one being shown.
RecycleAllExcept(static _ => false);
rowHeights.Clear();
pinned = null;
InvalidateMeasure();
}
/// <inheritdoc />
protected override Control? ScrollIntoView(int index)
{
if (index < 0 || index >= Items.Count)
{
return null;
}
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
container.Arrange(ItemRect(index, RowTops(Items.Count)));
pinned = index;
container.BringIntoView();
return container;
}
/// <inheritdoc />
protected override Control? ContainerFromIndex(int index) => realized.GetValueOrDefault(index);
/// <inheritdoc />
protected override int IndexFromContainer(Control container)
{
foreach (var (index, held) in realized)
{
if (ReferenceEquals(held, container))
{
return index;
}
}
return -1;
}
/// <inheritdoc />
protected override IEnumerable<Control>? GetRealizedContainers() => realized.Values;
/// <inheritdoc />
/// <remarks>
/// Left and right step one card, up and down one row of them — the movements the plain wrap panel's own
/// navigation offered, so the board's arrows do what they did, with the card stepped to built on demand.
/// </remarks>
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
{
var count = Items.Count;
if (count == 0)
{
return null;
}
var current = from is Control control ? IndexFromContainer(control) : -1;
var target = direction switch
{
NavigationDirection.First => 0,
NavigationDirection.Last => count - 1,
NavigationDirection.Left or NavigationDirection.Previous => current - 1,
NavigationDirection.Right or NavigationDirection.Next => current + 1,
NavigationDirection.Up => current - columns,
NavigationDirection.Down => current + columns,
_ => -1,
};
if (current < 0 && direction is not (NavigationDirection.First or NavigationDirection.Last))
{
target = 0;
}
if (target < 0 || target >= count)
{
if (!wrap)
{
return null;
}
target = (target % count + count) % count;
}
return ScrollIntoView(target);
}
/// <summary>The top of every row, and one more entry past the last row for the total height.</summary>
private double[] RowTops(int count)
{
var rows = (count + columns - 1) / columns;
var tops = new double[rows + 1];
var estimate = itemSize.Height > 0 ? itemSize.Height : FallbackRowHeight;
for (var row = 0; row < rows; row++)
{
tops[row + 1] = tops[row] + rowHeights.GetValueOrDefault(row, estimate);
}
return tops;
}
/// <summary>The first and last item in the rows that overlap the visible part, with the overscan.</summary>
private (int First, int Last) VisibleRange(double[] tops, int count)
{
// Before the first viewport arrives, a screenful from the top: the panel has to measure to something.
var top = viewportKnown ? viewport.Top - Overscan : 0;
var bottom = viewportKnown ? viewport.Bottom + Overscan : 1200;
var rows = tops.Length - 1;
var firstRow = -1;
var lastRow = -1;
for (var row = 0; row < rows; row++)
{
if (tops[row + 1] < top)
{
continue;
}
if (tops[row] > bottom)
{
break;
}
if (firstRow < 0)
{
firstRow = row;
}
lastRow = row;
}
return firstRow < 0
? (0, -1)
: (firstRow * columns, Math.Min(count - 1, ((lastRow + 1) * columns) - 1));
}
private Rect ItemRect(int index, double[] tops)
{
var row = index / columns;
var column = index % columns;
var height = row + 1 < tops.Length ? tops[row + 1] - tops[row] : itemSize.Height;
return new Rect(column * itemSize.Width, tops[Math.Min(row, tops.Length - 1)], itemSize.Width, height);
}
/// <summary>The container for an item, built or brought back from the pool if it is not already up.</summary>
private Control RealizedOrNew(int index)
{
if (realized.TryGetValue(index, out var existing))
{
return existing;
}
var generator = ItemContainerGenerator!;
var item = Items[index];
Control container;
if (generator.NeedsContainer(item, index, out var recycleKey))
{
var key = recycleKey ?? typeof(object);
if (recycled.TryGetValue(key, out var pool) && pool.TryPop(out var reused))
{
container = reused;
container.IsVisible = true;
}
else
{
container = generator.CreateContainer(item, index, recycleKey);
poolOf[container] = key;
AddInternalChild(container);
}
generator.PrepareItemContainer(container, item, index);
realized[index] = container;
generator.ItemContainerPrepared(container, item, index);
}
else
{
// An item that is its own container: built by whoever made the item, and never pooled.
container = (Control)item!;
AddInternalChild(container);
realized[index] = container;
}
return container;
}
/// <summary>Puts away every built container the predicate does not keep.</summary>
private void RecycleAllExcept(Func<int, bool> keep)
{
foreach (var index in realized.Keys.ToList())
{
if (keep(index))
{
continue;
}
var container = realized[index];
realized.Remove(index);
if (poolOf.TryGetValue(container, out var key))
{
ItemContainerGenerator!.ClearItemContainer(container);
container.IsVisible = false;
if (!recycled.TryGetValue(key, out var pool))
{
recycled[key] = pool = new Stack<Control>();
}
pool.Push(container);
}
else
{
RemoveInternalChild(container);
}
}
}
}
@@ -283,6 +283,9 @@ internal sealed class S3FileStore : IRemoteFileStore
/// have cost. /// have cost.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <inheritdoc />
public bool CanWriteFromAnOffset => false;
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(path); ArgumentNullException.ThrowIfNull(path);
@@ -122,6 +122,9 @@ public sealed record ClientPaths(string DataDirectory)
/// </remarks> /// </remarks>
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock"); public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
/// <summary>Where the application writes down why it died, when something nothing caught ends it.</summary>
public string CrashLogFile => Path.Combine(DataDirectory, "crash.log");
/// <summary>Creates the profile directory if it is not there yet.</summary> /// <summary>Creates the profile directory if it is not there yet.</summary>
/// <remarks> /// <remarks>
/// Separate from resolving the path, because resolving must never have a side effect: it is read /// Separate from resolving the path, because resolving must never have a side effect: it is read
@@ -80,6 +80,9 @@ public sealed class RelayRoute : IConnectionRoute
RelayProtocol.DurationLimitCloseCode => RelayProtocol.DurationLimitCloseCode =>
"The server relay closed the connection: it reached the longest session the server allows.", "The server relay closed the connection: it reached the longest session the server allows.",
RelayProtocol.AccessRevokedCloseCode =>
"The server relay closed the connection: you no longer have access to this host's vault.",
_ => null, _ => null,
}; };
} }
@@ -145,6 +145,26 @@ public sealed class VaultKnownHostStore : IKnownHostStore
} }
} }
/// <inheritdoc />
/// <remarks>From the snapshot, like <see cref="FindAsync"/>, and for the same reason.</remarks>
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken)
{
lock (gate)
{
IReadOnlyList<HostKeyPresentation> found = pins.Values
.Where(pin =>
KnownHostIdentity.Comparer.Equals(pin.Secret.Host, host) && pin.Secret.Port == port)
.Select(pin => new HostKeyPresentation(
pin.Secret.Host, pin.Secret.Port, pin.Secret.Algorithm, pin.Secret.Fingerprint))
.ToArray();
return ValueTask.FromResult(found);
}
}
/// <inheritdoc /> /// <inheritdoc />
/// <exception cref="InvalidOperationException"> /// <exception cref="InvalidOperationException">
/// The vault is not open, so there is nowhere to record trust. /// The vault is not open, so there is nowhere to record trust.
@@ -0,0 +1,85 @@
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// What unlocking with this machine's device key is called here, and what it asks the person for.
/// </summary>
/// <remarks>
/// <para>
/// The words used to be Windows's everywhere: "USE WINDOWS HELLO" on a Mac, "Waiting for Windows…" over a
/// Linux PIN prompt, and a settings row that told a MacBook it needed a TPM. Each desktop has its own device
/// key store with its own gesture — see <c>WindowsDeviceKeyStore</c>, <c>MacDeviceKeyStore</c> and
/// <c>LinuxDeviceKeyStore</c> — and a button has to name the one the person is about to be shown, or it reads
/// as a feature for some other machine.
/// </para>
/// <para>
/// One record rather than a property per sentence on the shell, so that the five places these words appear
/// cannot disagree about which platform they are on.
/// </para>
/// </remarks>
/// <param name="Name">What the feature is called, as a settings row's title.</param>
/// <param name="Button">The unlock card's button, in the capitals every button here uses.</param>
/// <param name="Wait">The status line while the platform's prompt is up.</param>
/// <param name="Gesture">What opens the keychain instead of the passphrase, to finish a sentence.</param>
/// <param name="Keeper">What holds the key, to finish "Registered · …".</param>
/// <param name="Needs">What a machine with no device key store lacks, as a sentence of its own.</param>
internal sealed record DeviceUnlockWording(
string Name,
string Button,
string Wait,
string Gesture,
string Keeper,
string Needs)
{
internal static DeviceUnlockWording Windows { get; } = new(
"Windows Hello on this machine",
"USE WINDOWS HELLO",
"Waiting for Windows…",
"a Windows confirmation",
"the TPM holds a device key",
"That needs a TPM and a Windows keystore willing to release the key.");
internal static DeviceUnlockWording Mac { get; } = new(
"Touch ID on this Mac",
"USE TOUCH ID",
"Waiting for Touch ID…",
"Touch ID or your login password",
"the Secure Enclave holds a device key",
"That needs a Mac with a Secure Enclave and a login password set.");
internal static DeviceUnlockWording Linux { get; } = new(
"A device PIN on this machine",
"USE DEVICE PIN",
"Waiting for your PIN…",
"a PIN the TPM checks",
"the TPM holds a device key",
"That needs a TPM 2.0 this account is allowed to use.");
internal static DeviceUnlockWording Android { get; } = new(
"Fingerprint unlock on this phone",
"USE FINGERPRINT",
"Waiting for your fingerprint…",
"your fingerprint",
"the phone's keystore holds a device key",
"That needs a screen lock and a keystore willing to release the key.");
/// <summary>The wording for the platform this process is running on.</summary>
internal static DeviceUnlockWording ForThisPlatform { get; } =
OperatingSystem.IsAndroid() ? Android
: OperatingSystem.IsMacOS() ? Mac
: OperatingSystem.IsLinux() ? Linux
: Windows;
/// <summary>The settings caption for a machine that can register.</summary>
internal string Offer =>
$"Registers this machine so a later launch can open the keychain with {Gesture} instead of your passphrase. Your passphrase keeps working.";
/// <summary>The settings caption for a machine that has.</summary>
internal string Registered => $"Registered · {Keeper} that can open the keychain with {Gesture}.";
/// <summary>The settings caption for a machine that cannot.</summary>
internal string Unavailable =>
$"This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. {Needs}";
/// <summary>The unlock button's tooltip.</summary>
internal string Tip => $"Opens the keychain with this machine's device key, after {Gesture}.";
}
@@ -566,7 +566,7 @@ internal sealed partial class HetznerImportViewModel(
// and visible rather than harmless and confusing. // and visible rather than harmless and confusing.
Refresh(resetSelection: true); Refresh(resetSelection: true);
Status = WhatWasStored(outcome.Hosts); Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
Step = HetznerImportStep.Done; Step = HetznerImportStep.Done;
} }
@@ -22,10 +22,27 @@ namespace DodoSSH.Client.Shell.ViewModels;
/// </param> /// </param>
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath); internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
/// <summary>What an import stored.</summary> /// <summary>What an import stored, and what it did not.</summary>
/// <param name="Hosts">Hosts written.</param> /// <param name="Hosts">Hosts written.</param>
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param> /// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
internal sealed record ImportOutcome(int Hosts, int Keys); /// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
/// <remarks>
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
/// </remarks>
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
{
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
internal string Caveats =>
(Refused switch
{
0 => string.Empty,
1 => " 1 was skipped because it is not a host this keychain can connect to.",
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
})
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
}
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary> /// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
/// <remarks> /// <remarks>
@@ -409,7 +426,7 @@ internal sealed partial class ImportViewModel(
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.", _ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
}; };
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}"; return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
} }
/// <summary> /// <summary>
@@ -1795,6 +1795,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
ActiveSettingsPage = null; ActiveSettingsPage = null;
IsImportOpen = false; IsImportOpen = false;
IsHetznerImportOpen = false; IsHetznerImportOpen = false;
// Drawn over Settings → Vaults. Left open, it was still up the next time Settings opened, over
// whichever page that was.
vaults.IsMembersPanelOpen = false;
Screen = settingsReturnScreen; Screen = settingsReturnScreen;
Surface = settingsReturnSurface; Surface = settingsReturnSurface;
} }
@@ -2129,12 +2133,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return; return;
} }
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None) try
.ConfigureAwait(true); {
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
.ConfigureAwait(true);
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it // The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing
// second means the menu and the screen behind it never disagree, even for a frame. // it second means the menu and the screen behind it never disagree, even for a frame.
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true); await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A command's exception is rethrown on the UI thread, where nothing catches it — so a cache that
// could not be written to, or a vault locked halfway through, used to close the application. The
// switches are redrawn from what is actually stored, so the menu does not claim the change.
RebuildVaultToggles();
StatusMessage = $"Could not change which vaults are shown: {exception.Message}";
return;
}
RebuildVaultToggles(); RebuildVaultToggles();
@@ -2815,8 +2832,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while /// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
/// deciding whether the prompt in front of them is the one this application asked for. /// deciding whether the prompt in front of them is the one this application asked for.
/// </remarks> /// </remarks>
private static string GestureWait => private static string GestureWait => DeviceUnlockWording.ForThisPlatform.Wait;
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
/// <summary>What unlocking with the device key is called on this platform; the views read it whole.</summary>
internal DeviceUnlockWording DeviceUnlock { get; } = DeviceUnlockWording.ForThisPlatform;
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary> /// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks> /// <remarks>
@@ -2944,13 +2963,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks> /// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken) private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{ {
// ◆ Which lock this unlock started under. Every await below is a moment the user can press Lock, and
// LockAsync can only close what has been handed over by then — so an unlock still attaching its
// stores used to finish after the lock and open the vault behind the lock screen.
var generation = locks;
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true); await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
// Before the vault view model, because that is what reads it — and read at all rather than defaulted // Before the vault view model, because that is what reads it — and read at all rather than defaulted
// to "everything shown", because a vault somebody set aside last week should still be set aside. // to "everything shown", because a vault somebody set aside last week should still be set aside.
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true); visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
Vault = new VaultViewModel( if (generation != locks)
{
await AbandonAsync(session).ConfigureAwait(true);
return;
}
var opened = Vault = new VaultViewModel(
session, session,
workspace, workspace,
knownHosts, knownHosts,
@@ -2974,9 +3004,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// are both !CanRegisterDevice, and only the second has anything to withdraw. // are both !CanRegisterDevice, and only the second has anything to withdraw.
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null; CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true); await OpenAsync(opened, generation, cancellationToken).ConfigureAwait(true);
}
AfterTheFirstLoad(Vault); /// <summary>Loads the vault just handed over, and starts it syncing — unless a lock has closed it since.</summary>
/// <remarks>
/// From here the vault belongs to the shell, so a lock closes it itself: there is nothing to undo, only
/// nothing more to do to a vault that is gone. Before these checks, a lock pressed while the first load
/// ran left this to carry on into a disposed session and a vault the shell no longer held.
/// </remarks>
private async Task OpenAsync(VaultViewModel opened, int generation, CancellationToken cancellationToken)
{
if (generation != locks)
{
return;
}
await opened.LoadAsync(cancellationToken).ConfigureAwait(true);
if (generation != locks)
{
return;
}
AfterTheFirstLoad(opened);
// After the first load, so the list is on screen before anything talks to a server. The loop is // After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the // started from the UI thread deliberately: every pass resumes here, which is what keeps the
@@ -2991,7 +3042,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// and unlocking must never wait on one. Everything the unlock screen promises about working // and unlocking must never wait on one. Everything the unlock screen promises about working
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and // offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
// the titlebar says OFFLINE until the round trip this starts has an answer. // the titlebar says OFFLINE until the round trip this starts has an answer.
Vault.StartAutoSync(); opened.StartAutoSync();
}
/// <summary>Puts away an unlock that a lock overtook before the vault was handed over.</summary>
/// <remarks>
/// Everything <see cref="LockAsync"/> would have closed had it been here to close: the stores attached to
/// this session, the switches read from it, and the session itself, whose keys nothing else now holds.
/// </remarks>
private async Task AbandonAsync(VaultSession session)
{
knownHosts.Close();
connectionLog.Close();
visibility = null;
await session.DisposeAsync().ConfigureAwait(true);
} }
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary> /// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
@@ -3253,6 +3318,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand] [RelayCommand]
private async Task LockAsync() private async Task LockAsync()
{ {
// Counted first, so an unlock still on its way in sees it at its next step and stands down rather than
// opening the vault after this has run. See AdoptAsync.
locks++;
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it // First, and before the session it read from goes: a synchronisation pass may be in flight, and it
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins // ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
// reappearing behind a lock screen. // reappearing behind a lock screen.
@@ -3300,6 +3369,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty] [ObservableProperty]
private bool isConfirmingSignOut; private bool isConfirmingSignOut;
/// <summary>How many times this shell has been locked, for an unlock to notice it was overtaken.</summary>
private int locks;
/// <summary> /// <summary>
/// What signing out costs, on this machine, right now. /// What signing out costs, on this machine, right now.
/// </summary> /// </summary>
@@ -4512,7 +4584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{ {
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab) if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{ {
tab.Ended(e.Outcome.End, e.Outcome.Description); tab.Ended(e.Outcome.End, e.Outcome.Description, clock.GetUtcNow());
// Only for the tab being looked at, which is what these three already ask. The rectangle does not // Only for the tab being looked at, which is what these three already ask. The rectangle does not
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and // change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
@@ -4992,7 +5064,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return null; return null;
} }
var elapsed = clock.GetUtcNow() - started; // Up to the end, for a shell that has one: how long it ran, not how long ago it started.
var until = Surface is ShellSurface.Terminal && SelectedTab?.EndedAt is { } ended
? ended
: clock.GetUtcNow();
var elapsed = until - started;
if (elapsed < TimeSpan.Zero) if (elapsed < TimeSpan.Zero)
{ {
@@ -5107,6 +5184,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
Screen = ShellScreen.Hosts; Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page; Surface = ShellSurface.Page;
} }
else
{
// Settings is a mode of the unlocked window, and it brings its own title bar while the main one
// is hidden. Locking or signing out from it — LOCK NOW is on Preferences, sign-out on Account —
// left the window in settings mode under the lock card: no title bar, so nothing to drag, minimise
// or close, and an unlock that came back to Settings rather than to Hosts.
LeaveSettings();
}
} }
/// <remarks> /// <remarks>
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>
/// An observable list whose whole contents can be swapped and announced once.
/// </summary>
/// <remarks>
/// <para>
/// For the lists rebuilt wholesale from the vault. They used to be emptied with <c>Clear</c> and refilled an
/// <c>Add</c> at a time, which announced every row separately — and two other view models listen to the host
/// list and redo their own work on every announcement: the transfers screen refilled its host picker, and the
/// shell walked every host against every tab. A reload of n hosts was n of each, so roughly n² rows touched;
/// three hundred hosts came to some ninety thousand notifications per reload.
/// </para>
/// <para>
/// One <see cref="NotifyCollectionChangedAction.Reset"/> instead, which is also exactly what <c>Clear</c> has
/// always said first, so a listener that coped with the old sequence copes with this. Nothing listening to
/// these lists reads the items an announcement carries; they all re-read the list.
/// </para>
/// </remarks>
internal sealed class ReplaceableCollection<T> : ObservableCollection<T>
{
/// <summary>Makes <paramref name="items"/> the whole of the list, and says so once.</summary>
internal void ReplaceAll(IEnumerable<T> items)
{
ArgumentNullException.ThrowIfNull(items);
CheckReentrancy();
Items.Clear();
foreach (var item in items)
{
Items.Add(item);
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
}
@@ -274,6 +274,15 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// </remarks> /// </remarks>
internal DateTimeOffset? StartedAt { get; set; } internal DateTimeOffset? StartedAt { get; set; }
/// <summary>When the session behind this tab ended, or null while it has not.</summary>
/// <remarks>
/// What stops the status bar's timer. It used to go on counting from <see cref="StartedAt"/> to now over a
/// shell that had exited an hour earlier, which made "how long that shell ran" — the fact the remark above
/// keeps <see cref="StartedAt"/> for — the one thing it no longer said. Stamped by <see cref="Ended"/> from
/// the shell's clock, and cleared whenever a new session takes the tab.
/// </remarks>
internal DateTimeOffset? EndedAt { get; private set; }
/// <summary> /// <summary>
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session. /// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
/// </summary> /// </summary>
@@ -620,6 +629,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
// stale-fact failure this pair exists to remove. // stale-fact failure this pair exists to remove.
EndReason = null; EndReason = null;
EndDescription = null; EndDescription = null;
EndedAt = null;
IsLive = true; IsLive = true;
State = TerminalTabState.Open; State = TerminalTabState.Open;
@@ -679,13 +689,14 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason. /// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal void Ended(SshSessionEnd end, string description) internal void Ended(SshSessionEnd end, string description, DateTimeOffset at)
{ {
if (State is not TerminalTabState.Open) if (State is not TerminalTabState.Open)
{ {
return; return;
} }
EndedAt ??= at;
EndReason = end; EndReason = end;
EndDescription = description; EndDescription = description;
IsLive = false; IsLive = false;
@@ -733,6 +744,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
// Cleared with the session they were facts about. Keeping them would put the previous connection's // Cleared with the session they were facts about. Keeping them would put the previous connection's
// cipher in the status bar beside a handshake that has not negotiated one yet. // cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null; StartedAt = null;
EndedAt = null;
Cipher = null; Cipher = null;
HostKeyAlgorithm = null; HostKeyAlgorithm = null;
IdentityLabel = null; IdentityLabel = null;
@@ -867,18 +867,35 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would /// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
/// be a second decrypted copy of a secret for no gain. /// be a second decrypted copy of a secret for no gain.
/// </remarks> /// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = []; internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
[ObservableProperty] [ObservableProperty]
private HostRowViewModel? selectedHost; private HostRowViewModel? selectedHost;
/// <summary>The buckets that can be browsed, which is the vault's list.</summary> /// <summary>The buckets that can be browsed, which is the vault's list.</summary>
/// <inheritdoc cref="Hosts" path="/remarks" /> /// <inheritdoc cref="Hosts" path="/remarks" />
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = []; internal ReplaceableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
[ObservableProperty] [ObservableProperty]
private ObjectStoreRowViewModel? selectedBucket; private ObjectStoreRowViewModel? selectedBucket;
/// <summary>The host and bucket somebody chose, as opposed to whatever the pickers show this instant.</summary>
/// <remarks>
/// <see cref="RefreshHosts"/> re-finds the selection by id, and it runs on every change to the vault's
/// lists — including the <c>Clear()</c> a reload starts with, at which point there is nothing to re-find
/// and the picker's own selection becomes null. Re-finding from that null on the next <c>Add</c> moved the
/// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then
/// dialled the first host with the password typed for theirs. So the choice is remembered here, written
/// only when a person changes the picker, and is what every refresh looks for.
/// </remarks>
private Guid? chosenHost;
/// <inheritdoc cref="chosenHost" path="/remarks" />
private Guid? chosenBucket;
/// <summary>Whether a selection change is <see cref="RefreshHosts"/> putting things back, not a choice.</summary>
private bool refreshingPickers;
/// <summary> /// <summary>
/// Whether there is a bucket to open at all. /// Whether there is a bucket to open at all.
/// </summary> /// </summary>
@@ -969,6 +986,17 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
[ObservableProperty] [ObservableProperty]
private string typedPassword = string.Empty; private string typedPassword = string.Empty;
/// <summary>The host showing when <see cref="TypedPassword"/> was typed, which is the only one it is for.</summary>
/// <remarks>
/// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first —
/// and the box does not empty when it does, because it cannot tell that from a reload refilling the list.
/// So the password is checked against this at the moment it would be sent instead, and never goes to a
/// host it was not typed for.
/// </remarks>
private Guid? typedPasswordFor;
partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId;
/// <remarks> /// <remarks>
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the /// It opens saying what the state is rather than what to do about it, and that is a v3 change the
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and /// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
@@ -1780,8 +1808,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return; return;
} }
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
if (!open.TryBuildConnectionRequest( if (!open.TryBuildConnectionRequest(
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal)) row.Host, password, out var request, out var identityLabel, out var refusal))
{ {
Status = refusal; Status = refusal;
return; return;
@@ -2259,6 +2289,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// ---- Moving files ---- // ---- Moving files ----
/// <summary>What a transfer asked for while another command is still running is told.</summary>
private const string StillBusy =
"Still working on the last request. Nothing was queued — try again when it has finished.";
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary> /// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
[RelayCommand] [RelayCommand]
private void Download() private void Download()
@@ -2276,7 +2310,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true); await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
ClearRemoteChecks(); ClearRemoteChecks();
}); },
StillBusy);
} }
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary> /// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
@@ -2298,7 +2333,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true); await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
ClearLocalChecks(); ClearLocalChecks();
}); },
StillBusy);
} }
/// <summary>Unticks everything on the host's side.</summary> /// <summary>Unticks everything on the host's side.</summary>
@@ -2363,13 +2399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal void QueueUploads(IReadOnlyList<string> paths) => internal void QueueUploads(IReadOnlyList<string> paths) =>
_ = RunAsync( _ = RunAsync(
"Working out what to upload…", "Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None)); () => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
/// <inheritdoc cref="QueueUploads" /> /// <inheritdoc cref="QueueUploads" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) => internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
_ = RunAsync( _ = RunAsync(
"Working out what to download…", "Working out what to download…",
() => QueueDownloadsAsync(rows, CancellationToken.None)); () => QueueDownloadsAsync(rows, CancellationToken.None),
StillBusy);
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken) internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
{ {
@@ -2550,7 +2588,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
_ = RunAsync( _ = RunAsync(
"Working out what to upload…", "Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None)); () => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
} }
/// <summary> /// <summary>
@@ -2677,9 +2716,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
foreach (var row in rows) foreach (var row in rows)
{ {
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
{
plan.Unsafe++;
continue;
}
if (row.IsFile) if (row.IsFile)
{ {
plan.Downloads.Add((Path.Combine(LocalPath, row.Name), row.Entry)); plan.Downloads.Add((local, row.Entry));
continue; continue;
} }
@@ -2695,11 +2740,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await WalkRemoteAsync( await WalkRemoteAsync(
row.FullPath, row.FullPath,
Path.Combine(LocalPath, row.Name), local,
plan, plan,
cancellationToken).ConfigureAwait(true); cancellationToken).ConfigureAwait(true);
} }
EnqueueDownloads(plan);
Status = Describe(plan, "download into", LocalPath);
}
/// <summary>Makes the directories a download plan needs here, then hands its files to the queue.</summary>
private void EnqueueDownloads(TransferPlan plan)
{
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length)) foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
{ {
try try
@@ -2719,8 +2772,6 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
plan.Queued++; plan.Queued++;
} }
Status = Describe(plan, "download into", LocalPath);
} }
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary> /// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
@@ -2774,30 +2825,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
return; return;
} }
switch (entry.Kind) PlanRemoteEntry(plan, pending, entry, toDirectory);
{
case SftpEntryKind.File:
plan.Downloads.Add((Path.Combine(toDirectory, entry.Name), entry));
break;
case SftpEntryKind.Directory:
pending.Enqueue((entry.FullPath, Path.Combine(toDirectory, entry.Name)));
break;
case SftpEntryKind.SymbolicLink:
plan.Links++;
break;
default:
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to
// create, and counting it as a refusal would overstate what went wrong.
plan.Skipped++;
break;
}
} }
} }
} }
/// <summary>Adds one entry of a remote listing to a download plan.</summary>
private static void PlanRemoteEntry(
TransferPlan plan,
Queue<(string Remote, string Local)> pending,
SftpEntry entry,
string toDirectory)
{
var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory
? LocalDirectory.ChildPath(toDirectory, entry.Name)
: null;
switch (entry.Kind)
{
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
plan.Unsafe++;
break;
case SftpEntryKind.File:
plan.Downloads.Add((here!, entry));
break;
case SftpEntryKind.Directory:
pending.Enqueue((entry.FullPath, here!));
break;
case SftpEntryKind.SymbolicLink:
plan.Links++;
break;
default:
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
// counting it as a refusal would overstate what went wrong.
plan.Skipped++;
break;
}
}
/// <summary>What an upload of this local file should carry to the host with it.</summary> /// <summary>What an upload of this local file should carry to the host with it.</summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
@@ -2893,6 +2962,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary> /// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
public int Skipped { get; set; } public int Skipped { get; set; }
/// <summary>Remote names that would land outside the directory they were meant for.</summary>
/// <remarks>See <see cref="LocalDirectory.ChildPath"/>.</remarks>
public int Unsafe { get; set; }
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary> /// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
public bool Truncated { get; set; } public bool Truncated { get; set; }
@@ -2937,6 +3010,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
said += $" {other} not a file."; said += $" {other} not a file.";
} }
if (plan.Unsafe > 0)
{
var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were";
said += $" {named} left on the host.";
}
if (plan.Truncated) if (plan.Truncated)
{ {
said += " The walk stopped at 5000 files; queue the rest a folder at a time."; said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
@@ -3162,13 +3241,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
$"Fetching {row.Name}…", $"Fetching {row.Name}…",
async () => async () =>
{ {
var copy = Path.Combine( var folder = Path.Combine(
Path.GetTempPath(), Path.GetTempPath(),
"dodossh-edits", "dodossh-edits",
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture), Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
row.Name);
Directory.CreateDirectory(Path.GetDirectoryName(copy)!); if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy)
{
Status = $"{row.Name} is not a name this machine can open as a file.";
return;
}
Directory.CreateDirectory(folder);
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true); await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
@@ -3902,7 +3986,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
} }
} }
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts(); /// <remarks>
/// Only the picker the list feeds. Refilling both on either list's change made every reload refill the host
/// picker once for the hosts and again for the buckets.
/// </remarks>
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (vault is { } open && ReferenceEquals(sender, open.ObjectStores))
{
RefreshPickers(hosts: false, buckets: true);
}
else
{
RefreshPickers(hosts: true, buckets: false);
}
}
/// <summary> /// <summary>
/// Rebuilds both pickers from the vault's lists. /// Rebuilds both pickers from the vault's lists.
@@ -3920,33 +4018,39 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// connected to. /// connected to.
/// </para> /// </para>
/// </remarks> /// </remarks>
private void RefreshHosts() private void RefreshHosts() => RefreshPickers(hosts: true, buckets: true);
private void RefreshPickers(bool hosts, bool buckets)
{ {
var host = SelectedHost?.EntityId; refreshingPickers = true;
var bucket = SelectedBucket?.EntityId;
Hosts.Clear(); try
if (vault is not { } open)
{ {
return; if (vault is not { } open)
} {
Hosts.ReplaceAll([]);
return;
}
foreach (var row in open.Hosts) if (hosts)
{
Hosts.ReplaceAll(open.Hosts);
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
}
if (buckets)
{
Buckets.ReplaceAll(open.ObjectStores);
SelectedBucket =
Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
}
}
finally
{ {
Hosts.Add(row); refreshingPickers = false;
} }
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
Buckets.Clear();
foreach (var row in open.ObjectStores)
{
Buckets.Add(row);
}
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
} }
/// <summary>The session, or a failure a queue row can carry.</summary> /// <summary>The session, or a failure a queue row can carry.</summary>
@@ -4073,11 +4177,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen /// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the /// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
/// pane permanently disabled. /// pane permanently disabled.
/// <para>
/// <paramref name="refusedWhileBusy"/> is for the commands somebody can fire while another is running —
/// a drop, or the download and upload buttons pressed twice — which used to vanish without a word: the
/// files were not queued and nothing said so. A command given one says why it did nothing instead.
/// </para>
/// </remarks> /// </remarks>
private async Task RunAsync(string busyMessage, Func<Task> work) private async Task RunAsync(string busyMessage, Func<Task> work, string? refusedWhileBusy = null)
{ {
if (IsBusy) if (IsBusy)
{ {
if (refusedWhileBusy is not null)
{
Status = refusedWhileBusy;
}
return; return;
} }
@@ -4106,8 +4220,30 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await SettleRemoteListingAsync().ConfigureAwait(true); await SettleRemoteListingAsync().ConfigureAwait(true);
} }
partial void OnSelectedHostChanged(HostRowViewModel? value) => partial void OnSelectedHostChanged(HostRowViewModel? value)
{
if (!refreshingPickers)
{
chosenHost = value?.EntityId;
// A person moving to another host gets an empty box rather than one still showing a password
// that, by typedPasswordFor, would no longer be sent.
if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor)
{
TypedPassword = string.Empty;
}
}
OnPropertyChanged(nameof(SelectedHostAsksForAPassword)); OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
}
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
{
if (!refreshingPickers)
{
chosenBucket = value?.EntityId;
}
}
/// <remarks> /// <remarks>
/// The password box follows this as well as the host, because it is shown only for a host that asks for /// The password box follows this as well as the host, because it is shown only for a host that asks for
@@ -1663,7 +1663,7 @@ internal sealed partial class VaultViewModel(
/// host key list checks itself against, so a filter applied here would change what the application can /// host key list checks itself against, so a filter applied here would change what the application can
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view. /// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
/// </remarks> /// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = []; internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary> /// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
/// <remarks> /// <remarks>
@@ -1677,7 +1677,7 @@ internal sealed partial class VaultViewModel(
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings. /// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = []; internal ReplaceableCollection<HostRowViewModel> VisibleHosts { get; } = [];
/// <summary>Whether the grid has anything to draw.</summary> /// <summary>Whether the grid has anything to draw.</summary>
/// <remarks> /// <remarks>
@@ -1891,7 +1891,7 @@ internal sealed partial class VaultViewModel(
/// groupless keychain is one flat grid and nothing above it. /// groupless keychain is one flat grid and nothing above it.
/// </para> /// </para>
/// </remarks> /// </remarks>
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = []; internal ReplaceableCollection<HostSectionViewModel> HostSections { get; } = [];
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary> /// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
/// <remarks> /// <remarks>
@@ -2003,7 +2003,7 @@ internal sealed partial class VaultViewModel(
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = []; internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
/// <summary>The buckets to show, unpushed local state included.</summary> /// <summary>The buckets to show, unpushed local state included.</summary>
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = []; internal ReplaceableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
/// <summary>The tags to show, with the number of hosts wearing each.</summary> /// <summary>The tags to show, with the number of hosts wearing each.</summary>
/// <remarks> /// <remarks>
@@ -2404,6 +2404,14 @@ internal sealed partial class VaultViewModel(
internal bool HasVaultItems => VaultItems.Count > 0; internal bool HasVaultItems => VaultItems.Count > 0;
/// <summary>Whether any row in the keychain table names a vault, which is whether its VAULT column has a heading.</summary>
/// <remarks>
/// Read off the rows rather than off how many vaults there are, because a row's badge is what the column
/// holds: the two agree today — see <see cref="VaultItemRowViewModel.VaultBadge"/> — and if they ever stop
/// agreeing, a heading over nothing is the thing this exists to avoid.
/// </remarks>
internal bool ShowsVaultColumn => VaultItems.Any(row => row.HasVaultBadge);
internal bool HasSelectedVaultItem => SelectedVaultItem is not null; internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary> /// <summary>
@@ -4706,18 +4714,14 @@ internal sealed partial class VaultViewModel(
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true); var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
Hosts.Clear();
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can // Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
// hold a host with the same label and both are shown: which vault it is in is what tells them // hold a host with the same label and both are shown: which vault it is in is what tells them
// apart, which is why the row carries the name rather than the list deduplicating. // apart, which is why the row carries the name rather than the list deduplicating. Swapped in whole,
foreach (var host in rows // and announced once: see ReplaceableCollection for what a row at a time cost.
Hosts.ReplaceAll(rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId) .OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture) .ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture)) .ThenBy(row => row.Label, StringComparer.CurrentCulture));
{
Hosts.Add(host);
}
SelectedHost = SelectionAfterReload(selectedId); SelectedHost = SelectionAfterReload(selectedId);
ApplyTheChosenHosts(); ApplyTheChosenHosts();
@@ -4808,13 +4812,9 @@ internal sealed partial class VaultViewModel(
var selectedId = SelectedObjectStore?.EntityId; var selectedId = SelectedObjectStore?.EntityId;
ObjectStores.Clear(); ObjectStores.ReplaceAll(listing.Items
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture)
foreach (var store in listing.Items .Select(store => new ObjectStoreRowViewModel(store)));
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
{
ObjectStores.Add(new ObjectStoreRowViewModel(store));
}
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId); SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
@@ -5380,12 +5380,7 @@ internal sealed partial class VaultViewModel(
// a walk up the group tree, and this is the map that walk is made against. // a walk up the group tree, and this is the map that walk is made against.
var parents = EffectiveParents(); var parents = EffectiveParents();
VisibleHosts.Clear(); VisibleHosts.ReplaceAll(Hosts.Where(host => Matches(host, parents)));
foreach (var host in Hosts.Where(host => Matches(host, parents)))
{
VisibleHosts.Add(host);
}
RebuildSidebarRows(); RebuildSidebarRows();
@@ -5395,7 +5390,7 @@ internal sealed partial class VaultViewModel(
// property directly, and a host that is not on screen is not one any of them should act on. // property directly, and a host that is not on screen is not one any of them should act on.
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null; SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear() // After the host selection, not before: this mirrors it, and the ListBox's own answer to the reset
// above is a null that has to be overwritten rather than read. // above is a null that has to be overwritten rather than read.
SelectedSidebarRow = SelectedHost; SelectedSidebarRow = SelectedHost;
@@ -5465,12 +5460,7 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
private void RebuildHostSections() private void RebuildHostSections()
{ {
HostSections.Clear(); HostSections.ReplaceAll(BuildHostSections());
foreach (var built in BuildHostSections())
{
HostSections.Add(built);
}
// Computed rather than stored, and none of the four has a change notification of its own — the same // Computed rather than stored, and none of the four has a change notification of its own — the same
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the // arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
@@ -6359,7 +6349,7 @@ internal sealed partial class VaultViewModel(
{ {
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The // Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
// other order would show a newly shared vault as an empty one until the minute after. // other order would show a newly shared vault as an empty one until the minute after.
await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true); var vaultsChanged = await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
// Every vault this session can read, not only the one new items are filed into. A team's // Every vault this session can read, not only the one new items are filed into. A team's
// vault that never synced would show its hosts exactly once — at the unlock that first // vault that never synced would show its hosts exactly once — at the unlock that first
@@ -6372,6 +6362,15 @@ internal sealed partial class VaultViewModel(
// that had just failed to sync, which is exactly the lie that flag exists to prevent. // that had just failed to sync, which is exactly the lie that flag exists to prevent.
LastSyncFailed = report.Any(vault => !vault.Succeeded); LastSyncFailed = report.Any(vault => !vault.Succeeded);
if (!vaultsChanged && !MayHaveChangedTheKeychain(report))
{
// Nothing to redraw, so nothing is decrypted: see MayHaveChangedTheKeychain. The one thing a
// rebuild did that still has to happen is the ago-text on each card, which is a minute older.
RestringLastConnected();
return report;
}
await ReloadAsync(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 // Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
@@ -6415,7 +6414,7 @@ internal sealed partial class VaultViewModel(
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing. /// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
/// </para> /// </para>
/// </remarks> /// </remarks>
private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken) private async Task<bool> AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{ {
var before = session.Vaults.Count; var before = session.Vaults.Count;
@@ -6423,12 +6422,41 @@ internal sealed partial class VaultViewModel(
if (admitted == 0 && session.Vaults.Count == before) if (admitted == 0 && session.Vaults.Count == before)
{ {
return; return false;
} }
VaultsChanged?.Invoke(this, EventArgs.Empty); VaultsChanged?.Invoke(this, EventArgs.Empty);
return true;
} }
/// <summary>Whether a pass could have left the keychain different from the lists on screen.</summary>
/// <remarks>
/// <para>
/// Every pass used to end by reading the whole keychain back — decrypting every host, key, password,
/// bucket and snippet and rebuilding every list from them — once a minute, whether or not a single item
/// had moved. Almost every pass moves nothing, so almost all of that was spent redrawing what was already
/// drawn.
/// </para>
/// <para>
/// ◆ Conservative on purpose. A failed vault counts as changed, because a pull can apply a page and then
/// fail on the next one, and a report with no counts is no evidence that nothing landed. Log entries do
/// not count — a machine reads back the ones it just wrote, and nothing on these lists is drawn from the
/// log; see <see cref="SyncReport.PulledItems"/>.
/// </para>
/// </remarks>
private static bool MayHaveChangedTheKeychain(IReadOnlyList<VaultSyncReport> report) =>
report.Any(vault => vault.Report is not { } pass
|| pass.PulledItems > 0
|| pass.PushedItems > 0
|| pass.Merged > 0
|| pass.Resurrected > 0
|| pass.Recreated > 0
|| pass.Pruned > 0
|| pass.DeletesAbandoned > 0
|| pass.Parked > 0
|| pass.ResyncedFromStart);
/// <summary> /// <summary>
/// Raised when a synchronisation pass found that the vaults this account can reach have changed. /// Raised when a synchronisation pass found that the vaults this account can reach have changed.
/// </summary> /// </summary>
@@ -8838,53 +8866,94 @@ internal sealed partial class VaultViewModel(
ArgumentNullException.ThrowIfNull(requests); ArgumentNullException.ThrowIfNull(requests);
var target = vaultId ?? session.ActiveVaultId; var target = vaultId ?? session.ActiveVaultId;
var hosts = 0; var tally = new ImportTally();
var keys = 0; var ran = false;
var refused = 0; string? failure = null;
await RunAsync( await RunAsync(
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…", requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
async () => async () =>
{ {
// Keyed on the path rather than on the material, and filled as the batch runs rather than up ran = true;
// front: the second host naming a file has to land on the id the first one produced, and
// nothing is reloaded until the batch finishes.
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var request in requests) try
{ {
if (!request.Host.TryValidate(out _)) await ImportEachAsync(requests, target, tally, cancellationToken).ConfigureAwait(true);
{ }
refused++; catch (Exception exception) when (exception is not OutOfMemoryException
continue; and not OperationCanceledException)
} {
// Reported, and the hosts written before it are still reloaded below: they are in the
var (keyId, created) = await BindImportedKeyAsync( // keychain whether or not the rest followed, and the screen has to say both.
request, bound, target, cancellationToken) failure = exception.Message;
.ConfigureAwait(true);
if (created)
{
keys++;
}
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts
.CreateAsync(target, host, cancellationToken)
.ConfigureAwait(true);
hosts++;
} }
await ReloadAsync(cancellationToken).ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = DescribeImport(hosts, keys, refused); Status = DescribeImport(tally.Hosts, tally.Keys, tally.Refused)
+ (failure is null ? string.Empty : $" The import stopped early: {failure}");
}).ConfigureAwait(true); }).ConfigureAwait(true);
if (!ran)
{
// RunAsync returns at once while anything else is running — a sync pass, a save — and the
// screen asking would otherwise have said it imported nothing as though that were the result.
failure = "the keychain was busy with something else. Nothing was imported; try again.";
}
await AutoSyncAsync(cancellationToken).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
return new ImportOutcome(hosts, keys); return new ImportOutcome(tally.Hosts, tally.Keys, tally.Refused, failure);
}
/// <summary>Writes each host of an import, and any key it brought, counting as it goes.</summary>
/// <remarks>
/// Counted into <paramref name="tally"/> rather than returned, so that when a write fails partway the
/// caller still knows how many landed before it — which is what it has to tell the person.
/// </remarks>
private async Task ImportEachAsync(
IReadOnlyList<ImportedHostRequest> batch,
Guid into,
ImportTally tally,
CancellationToken token)
{
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
// front: the second host naming a file has to land on the id the first one produced, and nothing
// is reloaded until the batch finishes.
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var request in batch)
{
if (!request.Host.TryValidate(out _))
{
tally.Refused++;
continue;
}
var (keyId, created) = await BindImportedKeyAsync(request, bound, into, token)
.ConfigureAwait(true);
if (created)
{
tally.Keys++;
}
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts.CreateAsync(into, host, token).ConfigureAwait(true);
tally.Hosts++;
}
}
/// <summary>What one import has written so far.</summary>
private sealed class ImportTally
{
internal int Hosts { get; set; }
internal int Keys { get; set; }
internal int Refused { get; set; }
} }
/// <summary>Finds or stores the key an imported host should be bound to.</summary> /// <summary>Finds or stores the key an imported host should be bound to.</summary>
@@ -13092,13 +13161,27 @@ internal sealed partial class VaultViewModel(
+ "the beginning. " + "the beginning. "
: string.Empty; : string.Empty;
// Not a matter for attention — the server had already deleted these, and the machine is only catching
// up — but said, because items going from the list unannounced looks like losing them.
var pruned = report.Pruned > 0
? $" {report.Pruned} item(s) deleted elsewhere while this machine was away were removed."
: string.Empty;
if (!report.NeedsAttention) if (!report.NeedsAttention)
{ {
return replayed + (report.Pulled == 0 && report.Pushed == 0 return replayed + (report.Pulled == 0 && report.Pushed == 0 && report.Pruned == 0
? "Already up to date." ? "Already up to date."
: $"Synchronised: {report.Pulled} in, {report.Pushed} out."); : $"Synchronised: {report.Pulled} in, {report.Pushed} out.") + pruned;
} }
var notes = AttentionNotes(report);
return replayed + "Synchronised, but: " + string.Join("; ", notes) + "." + pruned;
}
/// <summary>What a pass that needs attention says, one clause per thing.</summary>
private static List<string> AttentionNotes(SyncReport report)
{
var notes = new List<string>(); var notes = new List<string>();
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind // "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
@@ -13108,6 +13191,11 @@ internal sealed partial class VaultViewModel(
notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name"); notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
} }
if (report.Recreated > 0)
{
notes.Add($"{report.Recreated} item(s) deleted elsewhere long ago were put back, with this machine's changes");
}
if (report.DeletesAbandoned > 0) if (report.DeletesAbandoned > 0)
{ {
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit"); notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
@@ -13133,7 +13221,7 @@ internal sealed partial class VaultViewModel(
+ "before you can see anything written since"); + "before you can see anything written since");
} }
return replayed + "Synchronised, but: " + string.Join("; ", notes) + "."; return notes;
} }
private async Task RunAsync(string busyMessage, Func<Task> work) private async Task RunAsync(string busyMessage, Func<Task> work)
@@ -13416,6 +13504,16 @@ internal sealed partial class VaultViewModel(
}; };
} }
/// <summary>Re-reads what the keychain table says about its rows as a whole.</summary>
private void RaiseVaultItemFacts()
{
OnPropertyChanged(nameof(SectionSummary));
OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(ShowsVaultColumn));
OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage));
}
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary> /// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
/// <remarks> /// <remarks>
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> — /// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
@@ -13511,10 +13609,7 @@ internal sealed partial class VaultViewModel(
// sync every minute would otherwise move the detail pane out from under whoever was reading it. // sync every minute would otherwise move the detail pane out from under whoever was reading it.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId); SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(SectionSummary)); RaiseVaultItemFacts();
OnPropertyChanged(nameof(HasVaultItems));
OnPropertyChanged(nameof(TotalItemCount));
OnPropertyChanged(nameof(EmptySectionMessage));
// A reload replaces every row object, and the selection is restored by id — so the setter above may // A reload replaces every row object, and the selection is restored by id — so the setter above may
// not have fired even though the row this answers about is a different instance. Asked again here, // not have fired even though the row this answers about is a different instance. Asked again here,
@@ -1639,11 +1639,32 @@ internal sealed partial class VaultsViewModel(
} }
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come // Fire-and-forget on purpose, and the only place in this class that is: selection changes come
// from a list box, which has no cancellation token and no way to await. Wrapped here rather than // from a list box, which has no cancellation token and no way to await.
// inside the read, because the read is also awaited from inside a reload that already holds the //
// busy gate — and a second RunAsync there would find the gate held by its own caller and skip the // ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
// members list entirely. // a share, a reload — so a vault chosen in that moment never had its people read, and the panel
_ = RunAsync(() => LoadSelectedAsync(CancellationToken.None)); // went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
// and every answer for an earlier selection is discarded by it.
_ = LoadSelectionAsync();
}
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
private async Task LoadSelectionAsync()
{
try
{
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (DodoSshApiException exception)
{
Status = exception.Message;
}
catch (Exception exception) when (exception is not OutOfMemoryException
and not OperationCanceledException)
{
Status = exception.Message;
}
} }
partial void OnPendingActionChanged(VaultActionRequest? value) partial void OnPendingActionChanged(VaultActionRequest? value)
+32
View File
@@ -64,6 +64,22 @@ public interface IKnownHostStore
/// </remarks> /// </remarks>
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken); ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
/// <summary>Returns every key pinned for one endpoint, whatever the algorithm.</summary>
/// <remarks>
/// <para>
/// ◆ <b>What stops a pin being sidestepped by changing algorithm.</b> <see cref="FindAsync"/> answers
/// per algorithm, so on its own a host pinned for <c>ssh-ed25519</c> that is answered by somebody offering
/// only <c>ecdsa-sha2-nistp256</c> looks like a host never seen before — and "never seen before" is a
/// question the user is asked, where "changed" is a refusal. Knowing the endpoint has pins at all is what
/// lets the connection factory tell the two apart; see <c>SshNetConnectionFactory</c>.
/// </para>
/// <para>The same rule as <see cref="FindAsync"/>: no I/O.</para>
/// </remarks>
ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken);
/// <summary>Records a host key as trusted.</summary> /// <summary>Records a host key as trusted.</summary>
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken); ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
@@ -156,6 +172,22 @@ public sealed class InMemoryKnownHostStore : IKnownHostStore
} }
} }
/// <inheritdoc />
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
string host,
int port,
CancellationToken cancellationToken)
{
lock (gate)
{
IReadOnlyList<HostKeyPresentation> found = pins.Values
.Where(pin => KnownHostIdentity.Comparer.Equals(pin.Host, host) && pin.Port == port)
.ToArray();
return ValueTask.FromResult(found);
}
}
/// <inheritdoc /> /// <inheritdoc />
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken) public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
{ {
+8
View File
@@ -523,6 +523,14 @@ public interface IRemoteFileStore : IAsyncDisposable
/// <param name="cancellationToken">Abandons the open.</param> /// <param name="cancellationToken">Abandons the open.</param>
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken); Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
/// <summary>Whether <see cref="OpenWriteAsync"/> can start anywhere but the beginning.</summary>
/// <remarks>
/// True for SFTP, which is a file system. False for a bucket, where an object is written whole: an
/// interrupted upload there starts again rather than resuming, and the transfer queue reads this to know
/// — asking for an offset would be refused on every retry, leaving a row that can never finish.
/// </remarks>
bool CanWriteFromAnOffset => true;
/// <summary>Creates one directory, whose parent must exist.</summary> /// <summary>Creates one directory, whose parent must exist.</summary>
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken); Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
+133 -11
View File
@@ -187,7 +187,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
IProgress<SshConnectionPhase>? progress, IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken); var endpointPins = await knownHosts
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
.ConfigureAwait(false);
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
client.HostKeyReceived += gate.OnHostKeyReceived; client.HostKeyReceived += gate.OnHostKeyReceived;
@@ -217,17 +223,73 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
return gate; return gate;
} }
/// <summary>
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
/// </summary>
/// <remarks>
/// <para>
/// The server takes the first algorithm in the client's list that it also supports, so this is what
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
/// may not be, which is exactly the choice it must not be left with.
/// </para>
/// <para>
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
/// </para>
/// </remarks>
private static void PreferPinnedAlgorithms(
ConnectionInfo info,
IReadOnlyList<HostKeyPresentation> pins)
{
if (pins.Count == 0)
{
return;
}
var pinned = pins
.Select(pin => pin.Algorithm)
.ToHashSet(StringComparer.Ordinal);
var ordered = info.HostKeyAlgorithms
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
.ToList();
info.HostKeyAlgorithms.Clear();
foreach (var algorithm in ordered)
{
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
}
}
/// <summary> /// <summary>
/// Decides host key trust during the handshake, and remembers enough to explain a refusal. /// Decides host key trust during the handshake, and remembers enough to explain a refusal.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// Separate from the connect method because the decision is the security-relevant part and reads /// Separate from the connect method because the decision is the security-relevant part and reads
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch, /// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt. /// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
/// </para>
/// <para>
/// ◆ <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
/// — so somebody in the middle who simply offered a different key type got the first-use question
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
/// refusing a server that still has its pinned key.
/// </para>
/// </remarks> /// </remarks>
private sealed class HostKeyGate( private sealed class HostKeyGate(
IKnownHostStore knownHosts, IKnownHostStore knownHosts,
SshConnectionRequest request, SshConnectionRequest request,
IReadOnlyList<HostKeyPresentation> endpointPins,
IProgress<SshConnectionPhase>? progress, IProgress<SshConnectionPhase>? progress,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -264,13 +326,21 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
.GetAwaiter() .GetAwaiter()
.GetResult(); .GetResult();
if (pinned is null) if (pinned is null && endpointPins.Count == 0)
{ {
// Refused, not prompted. The caller decides, off the handshake thread. // Refused, not prompted. The caller decides, off the handshake thread.
e.CanTrust = false; e.CanTrust = false;
return; return;
} }
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
// against the endpoint's first pin. See the class remarks.
pinned ??= endpointPins
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
?.Fingerprint
?? endpointPins[0].Fingerprint;
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint); var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
mismatch = !matches; mismatch = !matches;
e.CanTrust = matches; e.CanTrust = matches;
@@ -398,7 +468,21 @@ internal sealed class SshNetConnection(
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty; public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <inheritdoc /> /// <inheritdoc />
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken) /// <remarks>
/// <para>
/// On the thread pool, because <c>CreateShellStream</c> is synchronous and is three round trips to the
/// server — open a channel, ask for a terminal, ask for a shell — and this used to make them on whichever
/// thread called it, which is the UI thread. On a distant or overloaded server the window stopped
/// painting for as long as they took, and the cancellation token it was given was read once, before any
/// of them.
/// </para>
/// <para>
/// SSH.NET's call cannot itself be cancelled, so a cancellation stops the wait rather than the call. A
/// shell that arrives after nobody is waiting for it is closed as it lands rather than left holding a
/// channel on the server.
/// </para>
/// </remarks>
public async Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{ {
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
@@ -406,15 +490,53 @@ internal sealed class SshNetConnection(
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here // 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
// only delays the first byte reaching the screen. // only delays the first byte reaching the screen.
var shell = client.CreateShellStream( var opening = Task.Run(
"xterm-256color", () => client.CreateShellStream(
effective.Columns, "xterm-256color",
effective.Rows, effective.Columns,
effective.PixelWidth, effective.Rows,
effective.PixelHeight, effective.PixelWidth,
4096); effective.PixelHeight,
4096),
CancellationToken.None);
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client)); try
{
var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
return new SshNetShellSession(shell, client);
}
catch (OperationCanceledException)
{
_ = CloseWhenItLandsAsync(opening);
throw;
}
}
/// <summary>Closes a shell nobody is waiting for any more, or lets its failure go unremarked.</summary>
/// <remarks>
/// A shell that failed to open after its caller gave up has nobody to tell, and the connection it was
/// on reports its own death through the session that owns it. The failures are the ones SSH.NET raises
/// for a channel or a connection that went away, named so that anything else still surfaces.
/// </remarks>
private static async Task CloseWhenItLandsAsync(Task<ShellStream> opening)
{
ShellStream shell;
try
{
shell = await opening.ConfigureAwait(false);
}
catch (Exception exception) when (exception is SshException
or System.Net.Sockets.SocketException
or ObjectDisposedException
or InvalidOperationException)
{
return;
}
await shell.DisposeAsync().ConfigureAwait(false);
} }
/// <inheritdoc /> /// <inheritdoc />
+34
View File
@@ -240,6 +240,15 @@ internal sealed class CachedItemRow
public bool IsDeleted { get; set; } public bool IsDeleted { get; set; }
public DateTimeOffset UpdatedAtUtc { get; set; } public DateTimeOffset UpdatedAtUtc { get; set; }
/// <summary>The last full pull that saw this item on the server; null if none has since this was kept.</summary>
/// <remarks>
/// The server collects tombstones after their retention (ADR 0003), so a machine away longer than that
/// is never told of some deletions: it is refused, starts over, and pulls everything that still exists.
/// What the pull from nothing did not mark is what the server no longer has. See
/// <c>ItemStore.SweepAsync</c>.
/// </remarks>
public long? SweepMark { get; set; }
} }
/// <summary> /// <summary>
@@ -350,6 +359,31 @@ internal sealed class SyncStateRow
/// decide which edit wins. /// decide which edit wins.
/// </remarks> /// </remarks>
public long ServerTimeSkewMs { get; set; } public long ServerTimeSkewMs { get; set; }
/// <summary>
/// The item types <see cref="Cursor"/> was read under, as this build names them; null before this was kept.
/// </summary>
/// <remarks>
/// A pull asks for the types this build can apply, and the cursor it gets back is past every change it
/// read — including the changes of types it did not ask for. So a build that adds a type to that list
/// and carries on from the old cursor never sees the items of that type made before it arrived. Kept
/// beside the cursor so a change of list starts the pull over; see <c>SyncEngine.PullAsync</c>.
/// </remarks>
public string? PulledTypes { get; set; }
/// <summary>The mark of the pull from nothing in progress, or null when none is.</summary>
/// <remarks>
/// Kept here rather than in memory because a pull from nothing can take several passes — the pages per
/// pass are capped — and what it has seen has to survive between them. See <see cref="CachedItemRow.SweepMark"/>.
/// </remarks>
public long? SweepMark { get; set; }
/// <summary>The highest change sequence that pull has read so far.</summary>
/// <remarks>
/// The sweep's upper bound: an item written here later than this — one this machine pushed while the
/// pull was under way — is newer than anything the pull has read, and not evidence of anything.
/// </remarks>
public long SweepSeenUpTo { get; set; }
} }
/// <summary>Something the merge had to override, or an item that could not be processed.</summary> /// <summary>Something the merge had to override, or an item that could not be processed.</summary>
+147
View File
@@ -118,6 +118,153 @@ public sealed class ItemStore(
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
/// <summary>
/// Writes the server's version of many items at once, in one transaction, in the order given.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="SaveAsync"/> for a page of pulled changes. A pull used to write each change on its own —
/// a context, a read, a commit — so a first sync of a keychain with a few thousand items was a few
/// thousand SQLite transactions, each one a sync to disk. This is one read for the rows that exist
/// already and one commit for the lot.
/// </para>
/// <para>
/// The same blind overwrite, and the same answer when one item appears more than once: they are applied
/// in order to the same row, so the last one given is the one kept — exactly what writing them one by one
/// would have left.
/// </para>
/// </remarks>
public async Task SaveManyAsync(IReadOnlyCollection<StoredItem> items, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(items);
if (items.Count == 0)
{
return;
}
var vaultIds = items.Select(item => item.VaultId).Distinct().ToList();
var entityIds = items.Select(item => item.EntityId).Distinct().ToList();
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
var existing = await context.Set<CachedItemRow>()
.Where(r => vaultIds.Contains(r.VaultId) && entityIds.Contains(r.EntityId))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var rows = existing.ToDictionary(row => (row.VaultId, row.EntityType, row.EntityId));
foreach (var item in items)
{
if (!rows.TryGetValue((item.VaultId, item.EntityType, item.EntityId), out var row))
{
row = new CachedItemRow
{
VaultId = item.VaultId,
EntityType = item.EntityType,
EntityId = item.EntityId,
};
context.Add(row);
rows.Add((item.VaultId, item.EntityType, item.EntityId), row);
}
Apply(row, item);
}
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>Marks the items a pull from nothing has just read as ones the server still has.</summary>
/// <remarks>One update per type in the page; see <see cref="SweepAsync"/>.</remarks>
public async Task MarkSeenAsync(
Guid vaultId,
long mark,
IReadOnlyCollection<(SyncEntityType Type, Guid Id)> seen,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(seen);
if (seen.Count == 0)
{
return;
}
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
foreach (var group in seen.GroupBy(item => item.Type))
{
var type = group.Key;
var ids = group.Select(item => item.Id).Distinct().ToList();
await context.Set<CachedItemRow>()
.Where(row => row.VaultId == vaultId && row.EntityType == type && ids.Contains(row.EntityId))
.ExecuteUpdateAsync(set => set.SetProperty(row => row.SweepMark, mark), cancellationToken)
.ConfigureAwait(false);
}
}
/// <summary>The highest change sequence held for a vault, or zero for none.</summary>
/// <remarks>Where a pull from nothing starts its sweep's upper bound; see <see cref="SweepAsync"/>.</remarks>
public async Task<long> HighestSequenceAsync(Guid vaultId, CancellationToken cancellationToken)
{
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
return await context.Set<CachedItemRow>()
.Where(row => row.VaultId == vaultId)
.MaxAsync(row => (long?)row.ChangeSequence, cancellationToken)
.ConfigureAwait(false) ?? 0;
}
/// <summary>
/// Removes the items a completed pull from nothing did not see, which are the ones the server no longer has.
/// </summary>
/// <remarks>
/// <para>
/// The other half of the server's tombstone collection. A machine that missed a deletion for longer than
/// the retention is refused its cursor and pulls everything again — and everything again does not include
/// the item, so its copy here would otherwise last for ever, and its next edit would push it back.
/// </para>
/// <para>
/// ◆ Two kinds of row are not evidence and are kept. One newer than <paramref name="seenUpTo"/> was
/// written by this machine's own push during the pull: the bound is the higher of what the cache held
/// when the pull began and what it has read since, and the server's head is past both, so a push lands
/// above it. One with a change still waiting to go is the user's work: it is
/// left for the push, which finds the server without the item and says so — see
/// <c>SyncEngine.RetryAsCreateAsync</c> — rather than being thrown away here unseen.
/// </para>
/// </remarks>
/// <returns>How many items went.</returns>
public async Task<int> SweepAsync(
Guid vaultId,
IReadOnlyCollection<SyncEntityType> types,
long mark,
long seenUpTo,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(types);
var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false);
// Only the types the pull asked for: a row of any other type was never going to be marked.
return await context.Set<CachedItemRow>()
.Where(row => row.VaultId == vaultId
&& types.Contains(row.EntityType)
&& (row.SweepMark == null || row.SweepMark != mark)
&& row.ChangeSequence <= seenUpTo
&& !context.Set<OutboxRow>().Any(pending =>
pending.VaultId == row.VaultId
&& pending.EntityType == row.EntityType
&& pending.EntityId == row.EntityId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary> /// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
/// <remarks> /// <remarks>
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make /// Only ever called for a row that is already a tombstone. Collecting a live item here would make
@@ -0,0 +1,469 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20261002104920_AddSyncStatePulledTypes")]
partial class AddSyncStatePulledTypes
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("Hidden")
.HasColumnType("INTEGER")
.HasColumnName("hidden");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<string>("PulledTypes")
.HasColumnType("TEXT")
.HasColumnName("pulled_types");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<Guid?>("DeviceId")
.HasColumnType("TEXT")
.HasColumnName("device_id");
b.Property<byte[]>("DeviceWrappedPrivateKey")
.HasColumnType("BLOB")
.HasColumnName("device_wrapped_private_key");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddSyncStatePulledTypes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "pulled_types",
table: "sync_state",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "pulled_types",
table: "sync_state");
}
}
}
@@ -0,0 +1,481 @@
// <auto-generated />
using System;
using DodoSSH.Client.Storage;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
[DbContext(typeof(ClientCacheContext))]
[Migration("20261002152108_AddSweepMarks")]
partial class AddSweepMarks
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<long>("ChangeSequence")
.HasColumnType("INTEGER")
.HasColumnName("change_sequence");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER")
.HasColumnName("is_deleted");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long?>("SweepMark")
.HasColumnType("INTEGER")
.HasColumnName("sweep_mark");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<int>("Version")
.HasColumnType("INTEGER")
.HasColumnName("version");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("VaultId", "EntityType", "EntityId")
.HasName("pk_item");
b.HasIndex("VaultId", "ChangeSequence")
.HasDatabaseName("ix_item_vault_id_change_sequence");
b.HasIndex("VaultId", "EntityType")
.HasDatabaseName("ix_item_vault_id_entity_type");
b.ToTable("item", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<byte[]>("WrappedKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_key");
b.HasKey("VaultId", "KeyGeneration")
.HasName("pk_vault_key_wrap");
b.ToTable("vault_key_wrap", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<bool>("Hidden")
.HasColumnType("INTEGER")
.HasColumnName("hidden");
b.Property<bool>("IsPersonal")
.HasColumnType("INTEGER")
.HasColumnName("is_personal");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("Permissions")
.HasColumnType("INTEGER")
.HasColumnName("permissions");
b.Property<bool>("RekeyRequired")
.HasColumnType("INTEGER")
.HasColumnName("rekey_required");
b.Property<Guid?>("TeamId")
.HasColumnType("TEXT")
.HasColumnName("team_id");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<byte[]>("WrappedVaultKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_vault_key");
b.HasKey("VaultId")
.HasName("pk_vault");
b.ToTable("vault", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Acknowledged")
.HasColumnType("INTEGER")
.HasColumnName("acknowledged");
b.Property<byte[]>("Detail")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("detail");
b.Property<long>("DetectedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("detected_at_utc");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int>("Kind")
.HasColumnType("INTEGER")
.HasColumnName("kind");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.HasKey("Id")
.HasName("pk_conflict");
b.HasIndex("VaultId", "Acknowledged")
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
b.HasIndex("VaultId", "EntityType", "EntityId")
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
b.ToTable("conflict", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
{
b.Property<long>("Sequence")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasColumnName("sequence");
b.Property<byte>("AadVersion")
.HasColumnType("INTEGER")
.HasColumnName("aad_version");
b.Property<byte?>("AncestorAadVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_aad_version");
b.Property<Guid?>("AncestorDataKeyId")
.HasColumnType("TEXT")
.HasColumnName("ancestor_data_key_id");
b.Property<uint?>("AncestorKeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_key_generation");
b.Property<byte[]>("AncestorPayload")
.HasColumnType("BLOB")
.HasColumnName("ancestor_payload");
b.Property<byte[]>("AncestorProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("ancestor_protected_fields");
b.Property<int?>("AncestorVersion")
.HasColumnType("INTEGER")
.HasColumnName("ancestor_version");
b.Property<byte[]>("AncestorWrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("ancestor_wrapped_data_key");
b.Property<int>("Attempts")
.HasColumnType("INTEGER")
.HasColumnName("attempts");
b.Property<Guid?>("DataKeyId")
.HasColumnType("TEXT")
.HasColumnName("data_key_id");
b.Property<Guid>("EntityId")
.HasColumnType("TEXT")
.HasColumnName("entity_id");
b.Property<int>("EntityType")
.HasColumnType("INTEGER")
.HasColumnName("entity_type");
b.Property<int?>("ExpectedVersion")
.HasColumnType("INTEGER")
.HasColumnName("expected_version");
b.Property<bool>("IsParked")
.HasColumnType("INTEGER")
.HasColumnName("is_parked");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("LastError")
.HasColumnType("TEXT")
.HasColumnName("last_error");
b.Property<int>("Operation")
.HasColumnType("INTEGER")
.HasColumnName("operation");
b.Property<Guid>("OperationId")
.HasColumnType("TEXT")
.HasColumnName("operation_id");
b.Property<byte[]>("Payload")
.HasColumnType("BLOB")
.HasColumnName("payload");
b.Property<byte[]>("ProtectedFields")
.HasColumnType("BLOB")
.HasColumnName("protected_fields");
b.Property<long>("QueuedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("queued_at_utc");
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<byte[]>("WrappedDataKey")
.HasColumnType("BLOB")
.HasColumnName("wrapped_data_key");
b.HasKey("Sequence")
.HasName("pk_outbox");
b.HasIndex("OperationId")
.IsUnique()
.HasDatabaseName("ix_outbox_operation_id");
b.HasIndex("VaultId", "EntityType", "EntityId")
.IsUnique()
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
b.HasIndex("VaultId", "IsParked", "Sequence")
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
b.ToTable("outbox", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<byte[]>("SealedRefreshToken")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("sealed_refresh_token");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.HasKey("Id")
.HasName("pk_remembered_sign_in");
b.ToTable("remembered_sign_in", null, t =>
{
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
});
});
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
{
b.Property<Guid>("VaultId")
.HasColumnType("TEXT")
.HasColumnName("vault_id");
b.Property<string>("Cursor")
.HasColumnType("TEXT")
.HasColumnName("cursor");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<long?>("LastPulledAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pulled_at_utc");
b.Property<long?>("LastPushedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc");
b.Property<string>("PulledTypes")
.HasColumnType("TEXT")
.HasColumnName("pulled_types");
b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms");
b.Property<long?>("SweepMark")
.HasColumnType("INTEGER")
.HasColumnName("sweep_mark");
b.Property<long>("SweepSeenUpTo")
.HasColumnType("INTEGER")
.HasColumnName("sweep_seen_up_to");
b.HasKey("VaultId")
.HasName("pk_sync_state");
b.ToTable("sync_state", (string)null);
});
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<Guid?>("DeviceId")
.HasColumnType("TEXT")
.HasColumnName("device_id");
b.Property<byte[]>("DeviceWrappedPrivateKey")
.HasColumnType("BLOB")
.HasColumnName("device_wrapped_private_key");
b.Property<string>("DisplayName")
.HasColumnType("TEXT")
.HasColumnName("display_name");
b.Property<string>("Email")
.HasColumnType("TEXT")
.HasColumnName("email");
b.Property<string>("Issuer")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("issuer");
b.Property<string>("KdfAlgorithm")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("kdf_algorithm");
b.Property<int>("KdfMemoryKibibytes")
.HasColumnType("INTEGER")
.HasColumnName("kdf_memory_kibibytes");
b.Property<int>("KdfParallelism")
.HasColumnType("INTEGER")
.HasColumnName("kdf_parallelism");
b.Property<int>("KdfPasses")
.HasColumnType("INTEGER")
.HasColumnName("kdf_passes");
b.Property<byte[]>("KdfSalt")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("kdf_salt");
b.Property<uint>("KeyGeneration")
.HasColumnType("INTEGER")
.HasColumnName("key_generation");
b.Property<string>("ServerUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("server_url");
b.Property<string>("Subject")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subject");
b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER")
.HasColumnName("updated_at_utc");
b.Property<Guid>("UserId")
.HasColumnType("TEXT")
.HasColumnName("user_id");
b.Property<byte[]>("WrappedPrivateKey")
.IsRequired()
.HasColumnType("BLOB")
.HasColumnName("wrapped_private_key");
b.HasKey("Id")
.HasName("pk_unlock_material");
b.ToTable("unlock_material", null, t =>
{
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
});
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DodoSSH.Client.Storage.Migrations
{
/// <inheritdoc />
public partial class AddSweepMarks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "sweep_mark",
table: "sync_state",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "sweep_seen_up_to",
table: "sync_state",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
migrationBuilder.AddColumn<long>(
name: "sweep_mark",
table: "item",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "sweep_mark",
table: "sync_state");
migrationBuilder.DropColumn(
name: "sweep_seen_up_to",
table: "sync_state");
migrationBuilder.DropColumn(
name: "sweep_mark",
table: "item");
}
}
}
@@ -59,6 +59,10 @@ namespace DodoSSH.Client.Storage.Migrations
.HasColumnType("BLOB") .HasColumnType("BLOB")
.HasColumnName("protected_fields"); .HasColumnName("protected_fields");
b.Property<long?>("SweepMark")
.HasColumnType("INTEGER")
.HasColumnName("sweep_mark");
b.Property<long>("UpdatedAtUtc") b.Property<long>("UpdatedAtUtc")
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasColumnName("updated_at_utc"); .HasColumnName("updated_at_utc");
@@ -362,10 +366,22 @@ namespace DodoSSH.Client.Storage.Migrations
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasColumnName("last_pushed_at_utc"); .HasColumnName("last_pushed_at_utc");
b.Property<string>("PulledTypes")
.HasColumnType("TEXT")
.HasColumnName("pulled_types");
b.Property<long>("ServerTimeSkewMs") b.Property<long>("ServerTimeSkewMs")
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasColumnName("server_time_skew_ms"); .HasColumnName("server_time_skew_ms");
b.Property<long?>("SweepMark")
.HasColumnType("INTEGER")
.HasColumnName("sweep_mark");
b.Property<long>("SweepSeenUpTo")
.HasColumnType("INTEGER")
.HasColumnName("sweep_seen_up_to");
b.HasKey("VaultId") b.HasKey("VaultId")
.HasName("pk_sync_state"); .HasName("pk_sync_state");
+31 -10
View File
@@ -44,6 +44,14 @@ public sealed record QueuedChange(
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges /// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
/// on the newest state with nothing for the user to arbitrate. /// on the newest state with nothing for the user to arbitrate.
/// </para> /// </para>
/// <para>
/// ◆ <b>Which is only true if an answer is applied to the operation it answers.</b> A push reads a row,
/// sends it, and acts on the reply some time later; a user who saves in between coalesces a new state into
/// that same row. So <see cref="CompleteAsync"/>, <see cref="ReviseAsync"/> and <see cref="ParkAsync"/>
/// take the operation that was sent and do nothing when the row has since become a different one. Keyed by
/// sequence alone, an acknowledgement of the earlier state deleted the row holding the later one, and the
/// edit was gone with nothing to say so.
/// </para>
/// </remarks> /// </remarks>
public sealed class OutboxStore( public sealed class OutboxStore(
IDbContextFactory<ClientCacheContext> contexts, IDbContextFactory<ClientCacheContext> contexts,
@@ -116,7 +124,7 @@ public sealed class OutboxStore(
/// </para> /// </para>
/// </remarks> /// </remarks>
public async Task<PendingOperation?> ReviseAsync( public async Task<PendingOperation?> ReviseAsync(
long sequence, PendingOperation revising,
SyncOperation operation, SyncOperation operation,
int? expectedVersion, int? expectedVersion,
EncryptedPayload? payload, EncryptedPayload? payload,
@@ -127,12 +135,18 @@ public sealed class OutboxStore(
var context = contexts.CreateDbContext(); var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false); await using var scope = context.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(revising);
var row = await context.Set<OutboxRow>() var row = await context.Set<OutboxRow>()
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken) .SingleOrDefaultAsync(
r => r.Sequence == revising.Sequence && r.OperationId == revising.OperationId,
cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (row is null) if (row is null)
{ {
// Completed, or replaced by a newer edit since it was read. Either way the merge was of a state
// that is no longer the one queued, and the next push will meet the server with the current one.
return null; return null;
} }
@@ -233,16 +247,19 @@ public sealed class OutboxStore(
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary> /// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) => public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
UpdateAsync(sequence, row => row.Attempts++, cancellationToken); UpdateAsync(sequence, operationId: null, row => row.Attempts++, cancellationToken);
/// <summary>Removes an operation the server accepted.</summary> /// <summary>Removes an operation the server accepted, unless a newer edit has replaced it since.</summary>
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken) /// <returns>Whether a row was removed.</returns>
public async Task<bool> CompleteAsync(PendingOperation operation, CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(operation);
var context = contexts.CreateDbContext(); var context = contexts.CreateDbContext();
await using var scope = context.ConfigureAwait(false); await using var scope = context.ConfigureAwait(false);
var removed = await context.Set<OutboxRow>() var removed = await context.Set<OutboxRow>()
.Where(r => r.Sequence == sequence) .Where(r => r.Sequence == operation.Sequence && r.OperationId == operation.OperationId)
.ExecuteDeleteAsync(cancellationToken) .ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
@@ -257,9 +274,10 @@ public sealed class OutboxStore(
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far /// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
/// worse, would block every change queued behind it in a vault the user can still write to. /// worse, would block every change queued behind it in a vault the user can still write to.
/// </remarks> /// </remarks>
public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) => public Task ParkAsync(PendingOperation operation, string reason, CancellationToken cancellationToken) =>
UpdateAsync( UpdateAsync(
sequence, operation.Sequence,
operation.OperationId,
row => row =>
{ {
row.IsParked = true; row.IsParked = true;
@@ -269,7 +287,7 @@ public sealed class OutboxStore(
/// <summary>Records a transient failure without parking the operation.</summary> /// <summary>Records a transient failure without parking the operation.</summary>
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) => public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
UpdateAsync(sequence, row => row.LastError = reason, cancellationToken); UpdateAsync(sequence, operationId: null, row => row.LastError = reason, cancellationToken);
private static Task<OutboxRow?> FindRowAsync( private static Task<OutboxRow?> FindRowAsync(
ClientCacheContext context, ClientCacheContext context,
@@ -302,6 +320,7 @@ public sealed class OutboxStore(
private async Task UpdateAsync( private async Task UpdateAsync(
long sequence, long sequence,
Guid? operationId,
Action<OutboxRow> mutate, Action<OutboxRow> mutate,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -309,7 +328,9 @@ public sealed class OutboxStore(
await using var scope = context.ConfigureAwait(false); await using var scope = context.ConfigureAwait(false);
var row = await context.Set<OutboxRow>() var row = await context.Set<OutboxRow>()
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken) .SingleOrDefaultAsync(
r => r.Sequence == sequence && (operationId == null || r.OperationId == operationId),
cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (row is null) if (row is null)
+11 -1
View File
@@ -35,6 +35,12 @@ public enum ConflictKind
/// <summary>The server refused the operation outright. Retrying will not help.</summary> /// <summary>The server refused the operation outright. Retrying will not help.</summary>
Rejected = 6, Rejected = 6,
/// <summary>
/// Someone else deleted an item this machine had edited, so long ago that the server had already let go
/// of the deletion. The edit was sent as the item again, under its own name and id.
/// </summary>
RecreatedAfterCollection = 7,
} }
/// <summary>What an offline unlock needs.</summary> /// <summary>What an offline unlock needs.</summary>
@@ -183,13 +189,17 @@ public sealed record PendingOperation(
/// <param name="LastPulledAt">When the last pull completed.</param> /// <param name="LastPulledAt">When the last pull completed.</param>
/// <param name="LastPushedAt">When the last push completed.</param> /// <param name="LastPushedAt">When the last push completed.</param>
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param> /// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
/// <param name="PulledTypes">The item types the cursor was read under; see <c>SyncStateRow.PulledTypes</c>.</param>
public sealed record StoredSyncState( public sealed record StoredSyncState(
Guid VaultId, Guid VaultId,
string? Cursor, string? Cursor,
uint KeyGeneration, uint KeyGeneration,
DateTimeOffset? LastPulledAt = null, DateTimeOffset? LastPulledAt = null,
DateTimeOffset? LastPushedAt = null, DateTimeOffset? LastPushedAt = null,
long ServerTimeSkewMs = 0); long ServerTimeSkewMs = 0,
string? PulledTypes = null,
long? SweepMark = null,
long SweepSeenUpTo = 0);
/// <summary>Something the merge overrode, or an item that could not be processed.</summary> /// <summary>Something the merge overrode, or an item that could not be processed.</summary>
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param> /// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
+7 -1
View File
@@ -46,7 +46,10 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
row.KeyGeneration, row.KeyGeneration,
row.LastPulledAtUtc, row.LastPulledAtUtc,
row.LastPushedAtUtc, row.LastPushedAtUtc,
row.ServerTimeSkewMs); row.ServerTimeSkewMs,
row.PulledTypes,
row.SweepMark,
row.SweepSeenUpTo);
} }
/// <summary>Records a vault's position.</summary> /// <summary>Records a vault's position.</summary>
@@ -72,6 +75,9 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
row.LastPulledAtUtc = state.LastPulledAt; row.LastPulledAtUtc = state.LastPulledAt;
row.LastPushedAtUtc = state.LastPushedAt; row.LastPushedAtUtc = state.LastPushedAt;
row.ServerTimeSkewMs = state.ServerTimeSkewMs; row.ServerTimeSkewMs = state.ServerTimeSkewMs;
row.PulledTypes = state.PulledTypes;
row.SweepMark = state.SweepMark;
row.SweepSeenUpTo = state.SweepSeenUpTo;
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
} }
+29 -20
View File
@@ -120,7 +120,7 @@ internal sealed class ItemReconciler<TSecret>(
{ {
return pending.Operation == SyncOperation.Delete return pending.Operation == SyncOperation.Delete
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user. // Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
? outbox.CompleteAsync(pending.Sequence, cancellationToken) ? outbox.CompleteAsync(pending, cancellationToken)
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken); : ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
} }
@@ -166,7 +166,7 @@ internal sealed class ItemReconciler<TSecret>(
} }
await outbox.ReviseAsync( await outbox.ReviseAsync(
pending.Sequence, pending,
SyncOperation.Upsert, SyncOperation.Upsert,
expectedVersion: null, expectedVersion: null,
kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1), kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1),
@@ -212,7 +212,7 @@ internal sealed class ItemReconciler<TSecret>(
{ {
// Field for field the same item: this is our own create coming back. Nothing to do but stop // Field for field the same item: this is our own create coming back. Nothing to do but stop
// trying to send it again. // trying to send it again.
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
return; return;
} }
@@ -362,7 +362,7 @@ internal sealed class ItemReconciler<TSecret>(
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
// Now the tombstone can stand. // Now the tombstone can stand.
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync( await conflicts.RecordAsync(
vaultId, vaultId,
@@ -385,7 +385,7 @@ internal sealed class ItemReconciler<TSecret>(
SyncReportBuilder report, SyncReportBuilder report,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync( await conflicts.RecordAsync(
vaultId, vaultId,
@@ -413,7 +413,7 @@ internal sealed class ItemReconciler<TSecret>(
var nextVersion = SyncVersions.NextVersion(remote.Version); var nextVersion = SyncVersions.NextVersion(remote.Version);
await outbox.ReviseAsync( await outbox.ReviseAsync(
pending.Sequence, pending,
SyncOperation.Upsert, SyncOperation.Upsert,
expectedVersion: remote.Version, expectedVersion: remote.Version,
kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion), kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion),
@@ -486,7 +486,7 @@ internal sealed class ItemReconciler<TSecret>(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await outbox.ParkAsync( await outbox.ParkAsync(
pending.Sequence, pending,
$"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.", $"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.",
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
@@ -512,7 +512,7 @@ internal sealed class ItemReconciler<TSecret>(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await outbox.ParkAsync( await outbox.ParkAsync(
pending.Sequence, pending,
$"The local or the server copy of this {kind.Noun} could not be decrypted.", $"The local or the server copy of this {kind.Noun} could not be decrypted.",
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
@@ -546,16 +546,25 @@ internal static class ItemMirror
Guid vaultId, Guid vaultId,
SyncChange change, SyncChange change,
CancellationToken cancellationToken) => CancellationToken cancellationToken) =>
items.SaveAsync( items.SaveAsync(ToStored(vaultId, change), cancellationToken);
new StoredItem(
vaultId, /// <summary>Mirrors a run of changes in one write; see <see cref="ItemStore.SaveManyAsync"/>.</summary>
change.EntityType, internal static Task WriteManyAsync(
change.EntityId, ItemStore items,
change.Version, Guid vaultId,
change.ChangeSequence, IReadOnlyList<SyncChange> changes,
change.Payload, CancellationToken cancellationToken) =>
change.PlaintextFields, items.SaveManyAsync(changes.Select(change => ToStored(vaultId, change)).ToList(), cancellationToken);
change.Operation == SyncOperation.Delete,
change.UpdatedAt), private static StoredItem ToStored(Guid vaultId, SyncChange change) =>
cancellationToken); new(
vaultId,
change.EntityType,
change.EntityId,
change.Version,
change.ChangeSequence,
change.Payload,
change.PlaintextFields,
change.Operation == SyncOperation.Delete,
change.UpdatedAt);
} }
+292 -34
View File
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security.Cryptography;
using DodoSSH.Client.Api; using DodoSSH.Client.Api;
using DodoSSH.Client.Storage; using DodoSSH.Client.Storage;
using DodoSSH.Contracts; using DodoSSH.Contracts;
@@ -127,7 +128,7 @@ public sealed class SyncEngine
SyncReportBuilder report, SyncReportBuilder report,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false); var state = await ReadStateAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
// At most one restart per pull. A server that rejects the cursor it has just issued is not // At most one restart per pull. A server that rejects the cursor it has just issued is not
// telling this client anything it can act on, and replaying the whole log against it would turn // telling this client anything it can act on, and replaying the whole log against it would turn
@@ -154,23 +155,13 @@ public sealed class SyncEngine
continue; continue;
} }
foreach (var change in response.Changes) await ApplyPageAsync(vaultId, response.Changes, report, cancellationToken).ConfigureAwait(false);
{ CountPulled(response.Changes, report);
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
report.Pulled++;
// Counted apart for the same reason the pushed ones are: a machine reads back the log
// entries it just wrote, so a pass that "pulled five changes" may have carried nothing
// anybody did. See SyncReport.PulledItems.
if (IsLog(change.EntityType))
{
report.PulledLogEntries++;
}
}
var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal); var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal);
state = Record(vaultId, state, response, report); state = Record(vaultId, state, response, report);
state = await CarrySweepAsync(vaultId, state, response, report, cancellationToken).ConfigureAwait(false);
await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false); await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false);
if (!response.HasMore) if (!response.HasMore)
@@ -187,15 +178,22 @@ public sealed class SyncEngine
} }
} }
/// <summary> private static void CountPulled(IReadOnlyList<SyncChange> changes, SyncReportBuilder report)
/// Whether the server refused the cursor this client sent, rather than failing for some other reason. {
/// </summary> foreach (var change in changes)
/// <remarks> {
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a report.Pulled++;
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface. // Counted apart for the same reason the pushed ones are: a machine reads back the log entries it
/// It is also what keeps the retry from looping — the restarted request sends no cursor. // just wrote, so a pass that "pulled five changes" may have carried nothing anybody did. See
/// </remarks> // SyncReport.PulledItems.
if (IsLog(change.EntityType))
{
report.PulledLogEntries++;
}
}
}
/// <summary>Whether a change is one the machine wrote about itself rather than one somebody made.</summary> /// <summary>Whether a change is one the machine wrote about itself rather than one somebody made.</summary>
/// <remarks> /// <remarks>
/// Used only for reporting. Log entries sync exactly like every other item — they are pulled, pushed, /// Used only for reporting. Log entries sync exactly like every other item — they are pulled, pushed,
@@ -205,10 +203,46 @@ public sealed class SyncEngine
private static bool IsLog(SyncEntityType type) => private static bool IsLog(SyncEntityType type) =>
type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry; type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
/// <summary>
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
/// </summary>
/// <remarks>
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
/// </remarks>
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) => private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
!string.IsNullOrEmpty(cursor) !string.IsNullOrEmpty(cursor)
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal); && string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
/// <remarks>
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
/// starts over once for the same reason: it may already have missed some.
/// </remarks>
private async Task<StoredSyncState> ReadStateAsync(
Guid vaultId,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
if (state.Cursor is not null && !string.Equals(state.PulledTypes, PulledTypes, StringComparison.Ordinal))
{
return await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false);
}
// A pull from nothing that is not a restart — the vault's first, or a cache reset — sweeps as well.
// There is usually nothing to remove; the one time there is, it is the same case.
return state is { Cursor: null, SweepMark: null }
? await SweepingAsync(state, cancellationToken).ConfigureAwait(false)
: state;
}
/// <summary> /// <summary>
/// Forgets this vault's position and reads the log again from the beginning. /// Forgets this vault's position and reads the log again from the beginning.
/// </summary> /// </summary>
@@ -233,12 +267,10 @@ public sealed class SyncEngine
/// cache itself is the thing in doubt. /// cache itself is the thing in doubt.
/// </para> /// </para>
/// <para> /// <para>
/// That leaves one gap, and it is worth naming rather than leaving to be discovered: once the server /// A replay does not carry a deletion the server has collected — <c>DodoOptions.TombstoneRetentionDays</c>
/// starts collecting tombstones — <c>DodoOptions.TombstoneRetentionDays</c>, not implemented yet — a /// — and that is the commonest refusal of all: a machine away longer than the retention. The rows it no
/// replay no longer carries a deletion older than the retention window. A machine that missed such a /// longer hears about are the ones the replay does not mark, and are swept when it ends; see
/// delete and then had its cursor refused would keep the row. Nothing here can tell that from a row /// <see cref="CarrySweepAsync"/>.
/// the server still has, so the answer when it matters will be to clear the mirror as well, not to
/// guess.
/// </para> /// </para>
/// <para> /// <para>
/// Written down before the replay begins, so a process that dies mid-replay starts the next one from /// Written down before the replay begins, so a process that dies mid-replay starts the next one from
@@ -250,7 +282,7 @@ public sealed class SyncEngine
SyncReportBuilder report, SyncReportBuilder report,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var restarted = state with { Cursor = null }; var restarted = await SweepingAsync(state with { Cursor = null }, cancellationToken).ConfigureAwait(false);
await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false); await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false);
@@ -259,6 +291,68 @@ public sealed class SyncEngine
return restarted; return restarted;
} }
/// <summary>The state with a sweep begun: a new mark, and the bound set from what the cache holds now.</summary>
/// <remarks>
/// The mark is random rather than the time, because a sweep must never share one with the last: rows that
/// one marked would count as seen by this one. A clock can stand still, or go back.
/// </remarks>
private async Task<StoredSyncState> SweepingAsync(StoredSyncState state, CancellationToken cancellationToken) =>
state with
{
SweepMark = BitConverter.ToInt64(RandomNumberGenerator.GetBytes(sizeof(long))),
SweepSeenUpTo = await items.HighestSequenceAsync(state.VaultId, cancellationToken).ConfigureAwait(false),
};
/// <summary>
/// Marks what a pull from nothing has read, and at its end removes what it never did.
/// </summary>
/// <remarks>
/// <para>
/// The server forgets a deletion once it is past its retention. A machine that was away longer is
/// refused its cursor and reads everything again — and everything again is the items that still exist,
/// with nothing said about the ones that went. Without this their rows stayed here for good, and the
/// next edit of one put it back on the server for everybody.
/// </para>
/// <para>
/// A pull from nothing can take more than one pass, so the mark is in the stored state, not in memory.
/// Every pull from nothing sweeps — the first one ever as well, where there is nothing to remove. What
/// is kept, and why, is <see cref="ItemStore.SweepAsync"/>'s to say.
/// </para>
/// </remarks>
private async Task<StoredSyncState> CarrySweepAsync(
Guid vaultId,
StoredSyncState state,
SyncPullResponse response,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
if (state.SweepMark is not { } mark)
{
return state;
}
var read = response.Changes.Where(change => reconcilers.ContainsKey(change.EntityType)).ToList();
await items.MarkSeenAsync(
vaultId,
mark,
read.Select(change => (change.EntityType, change.EntityId)).ToList(),
cancellationToken).ConfigureAwait(false);
var seenUpTo = read.Aggregate(state.SweepSeenUpTo, (highest, change) => Math.Max(highest, change.ChangeSequence));
if (response.HasMore)
{
return state with { SweepSeenUpTo = seenUpTo };
}
report.Pruned += await items
.SweepAsync(vaultId, ItemKinds.SyncedTypes, mark, seenUpTo, cancellationToken)
.ConfigureAwait(false);
return state with { SweepMark = null, SweepSeenUpTo = 0 };
}
private StoredSyncState Record( private StoredSyncState Record(
Guid vaultId, Guid vaultId,
StoredSyncState state, StoredSyncState state,
@@ -282,9 +376,69 @@ public sealed class SyncEngine
KeyGeneration = response.CurrentKeyGeneration, KeyGeneration = response.CurrentKeyGeneration,
LastPulledAt = now, LastPulledAt = now,
ServerTimeSkewMs = skew, ServerTimeSkewMs = skew,
PulledTypes = PulledTypes,
}; };
} }
/// <summary>The types this build pulls, in a form a stored state can be compared against.</summary>
private static string PulledTypes { get; } =
string.Join(',', ItemKinds.SyncedTypes.Select(type => (int)type).Order());
/// <summary>Applies one page of pulled changes, in order, with as few writes as that allows.</summary>
/// <remarks>
/// <para>
/// Each change used to cost a write to mirror it and a read to look for a local edit of the same item —
/// two or more SQLite transactions apiece, which on a first sync of a large keychain was most of the time
/// the sync took. The outbox is read once per page instead, and every run of changes with no local edit
/// waiting is mirrored in a single write.
/// </para>
/// <para>
/// ◆ A change that does have an edit waiting goes through <see cref="ApplyAsync"/> exactly as before, with
/// the run in front of it written first: the reconciler reads the mirror and the outbox as they stand at
/// that change, and both are what the one-by-one loop would have left them as. The one-read snapshot only
/// decides which path a change takes; the path that matters re-reads the outbox for itself.
/// </para>
/// </remarks>
private async Task ApplyPageAsync(
Guid vaultId,
IReadOnlyList<SyncChange> changes,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
if (changes.Count == 0)
{
return;
}
var waiting = (await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false))
.Select(pending => (pending.EntityType, pending.EntityId))
.ToHashSet();
var run = new List<SyncChange>();
foreach (var change in changes)
{
if (!reconcilers.ContainsKey(change.EntityType))
{
// See ApplyAsync: a type this build cannot sync is neither mirrored nor reconciled.
continue;
}
if (!waiting.Contains((change.EntityType, change.EntityId)))
{
run.Add(change);
continue;
}
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
run.Clear();
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
}
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
}
private async Task ApplyAsync( private async Task ApplyAsync(
Guid vaultId, Guid vaultId,
SyncChange change, SyncChange change,
@@ -446,7 +600,7 @@ public sealed class SyncEngine
} }
await outbox.ReviseAsync( await outbox.ReviseAsync(
operation.Sequence, operation,
operation.Operation, operation.Operation,
operation.ExpectedVersion, operation.ExpectedVersion,
resealed, resealed,
@@ -458,6 +612,17 @@ public sealed class SyncEngine
return resealed; return resealed;
} }
/// <summary>Whether a refused operation is a delete of an item the server never had.</summary>
/// <remarks>
/// A delete queued over a create that never reached the server carries no expected version, and the
/// server's only reason to refuse one of those is that it has no such item — which is the state the delete
/// asked for, so it is done. It used to be parked as a rejection: a "conflict" about an item nobody else
/// had ever seen, which the user could neither resolve nor dismiss. A create that did land is no exception
/// to this — the server has the item then, and applies the delete.
/// </remarks>
private static bool DeletesSomethingNeverCreated(PendingOperation operation) =>
operation is { Operation: SyncOperation.Delete, ExpectedVersion: null };
/// <returns>Whether this answer warrants another push round.</returns> /// <returns>Whether this answer warrants another push round.</returns>
private async Task<bool> HandleAsync( private async Task<bool> HandleAsync(
Guid vaultId, Guid vaultId,
@@ -500,6 +665,10 @@ public sealed class SyncEngine
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
return false; return false;
case SyncOperationStatus.Invalid when DeletesSomethingNeverCreated(operation):
await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
return false;
case SyncOperationStatus.Invalid: case SyncOperationStatus.Invalid:
await RejectAsync( await RejectAsync(
vaultId, vaultId,
@@ -551,7 +720,76 @@ public sealed class SyncEngine
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
} }
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false); var completed = await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
if (!completed && canMirror && !isDelete)
{
await RebaseOntoAcceptedAsync(vaultId, operation, version, cancellationToken).ConfigureAwait(false);
}
}
/// <summary>
/// Moves an edit made while <paramref name="accepted"/> was in flight onto the version that push produced.
/// </summary>
/// <remarks>
/// <para>
/// The user saved again between the push being read and its answer arriving, so the row now holds a
/// later state branched from the same base as the one the server just took. Left alone it is still the
/// user's edit and still goes — but it goes expecting the old version, comes back <c>Conflict</c>, and is
/// merged against an ancestor that predates the save the server already has, which makes every field
/// changed twice look like a disagreement with somebody else. Its true parent is the state just accepted,
/// so that becomes its ancestor and expected version, and its payload is re-sealed at the version it
/// will now produce, because the AAD binds that.
/// </para>
/// <para>
/// Only for a row still branched from the same base. One that is not has been revised by something else
/// since, and the ordinary conflict path is the one that knows what to do with it; so is a row whose
/// payload this session cannot re-seal.
/// </para>
/// </remarks>
private async Task RebaseOntoAcceptedAsync(
Guid vaultId,
PendingOperation accepted,
int version,
CancellationToken cancellationToken)
{
var later = await outbox
.FindAsync(vaultId, accepted.EntityType, accepted.EntityId, cancellationToken)
.ConfigureAwait(false);
if (later is null || later.ExpectedVersion != accepted.ExpectedVersion || accepted.Payload is null)
{
return;
}
EncryptedPayload? payload = null;
if (later.Payload is { } queued)
{
payload = PayloadReseal.TryReseal(
keyring,
vaultId,
later.EntityType,
later.EntityId,
queued,
openAtVersion: SyncVersions.NextVersion(later.ExpectedVersion),
sealAtVersion: SyncVersions.NextVersion(version));
if (payload is null)
{
return;
}
}
await outbox.ReviseAsync(
later,
later.Operation,
version,
payload,
later.Fields,
new StoredAncestor(version, accepted.Payload, accepted.Fields),
cancellationToken)
.ConfigureAwait(false);
} }
private async Task<bool> ResolveAsync( private async Task<bool> ResolveAsync(
@@ -617,7 +855,7 @@ public sealed class SyncEngine
if (operation.Operation == SyncOperation.Delete) if (operation.Operation == SyncOperation.Delete)
{ {
// Nothing there to delete, so the intent is already satisfied. // Nothing there to delete, so the intent is already satisfied.
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false); await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
return false; return false;
} }
@@ -629,6 +867,26 @@ public sealed class SyncEngine
if (failure is null) if (failure is null)
{ {
// An edit of a version this machine once saw means the server had the item and has since let go
// of it: deleted elsewhere, and the deletion collected past its retention before this machine
// heard of it. The edit is the user's work and goes back, but not silently — somebody deleted
// this on purpose. A create has no expected version and is the ordinary case.
if (operation.ExpectedVersion is not null)
{
await conflicts.RecordAsync(
vaultId,
operation.EntityType,
operation.EntityId,
ConflictKind.RecreatedAfterCollection,
ConflictDetails.Encode(
"This was deleted elsewhere while this machine had unsaved changes to it, long enough ago "
+ "that the server no longer remembered the deletion. The changes were kept and the item "
+ "was put back."),
cancellationToken).ConfigureAwait(false);
report.Recreated++;
}
return true; return true;
} }
@@ -645,7 +903,7 @@ public sealed class SyncEngine
SyncReportBuilder report, SyncReportBuilder report,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
await outbox.ParkAsync(operation.Sequence, reason, cancellationToken).ConfigureAwait(false); await outbox.ParkAsync(operation, reason, cancellationToken).ConfigureAwait(false);
await conflicts.RecordAsync( await conflicts.RecordAsync(
vaultId, vaultId,
+22 -3
View File
@@ -84,6 +84,17 @@ public sealed record SyncOptions
/// changed anything, which is otherwise the sort of thing that looks like a fault. /// changed anything, which is otherwise the sort of thing that looks like a fault.
/// </para> /// </para>
/// </param> /// </param>
/// <param name="PushedLogEntries">How many of <paramref name="Pushed"/> were log entries.</param>
/// <param name="PulledLogEntries">How many of <paramref name="Pulled"/> were log entries.</param>
/// <param name="Recreated">
/// Items this machine had edited that somebody else had deleted so long ago the server no longer
/// remembered it. The edit was kept and the item sent again under its own name — and the user is told,
/// because the deletion was somebody's decision.
/// </param>
/// <param name="Pruned">
/// Items removed from this machine because a read from the beginning found the server no longer has them:
/// deleted elsewhere while this machine was away for longer than the server keeps deletions.
/// </param>
public sealed record SyncReport( public sealed record SyncReport(
Guid VaultId, Guid VaultId,
int Pulled, int Pulled,
@@ -99,11 +110,13 @@ public sealed record SyncReport(
bool RoundsExhausted, bool RoundsExhausted,
bool ResyncedFromStart, bool ResyncedFromStart,
int PushedLogEntries = 0, int PushedLogEntries = 0,
int PulledLogEntries = 0) int PulledLogEntries = 0,
int Recreated = 0,
int Pruned = 0)
{ {
/// <summary>Whether anything happened that a user should be told about.</summary> /// <summary>Whether anything happened that a user should be told about.</summary>
public bool NeedsAttention => public bool NeedsAttention =>
Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired; Resurrected > 0 || Recreated > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
/// <summary> /// <summary>
/// Operations the server accepted that were somebody's own work rather than a log entry. /// Operations the server accepted that were somebody's own work rather than a log entry.
@@ -160,6 +173,10 @@ internal sealed class SyncReportBuilder(Guid vaultId)
internal bool ResyncedFromStart { get; set; } internal bool ResyncedFromStart { get; set; }
internal int Recreated { get; set; }
internal int Pruned { get; set; }
internal SyncReport Build() => internal SyncReport Build() =>
new( new(
vaultId, vaultId,
@@ -176,7 +193,9 @@ internal sealed class SyncReportBuilder(Guid vaultId)
RoundsExhausted, RoundsExhausted,
ResyncedFromStart, ResyncedFromStart,
PushedLogEntries, PushedLogEntries,
PulledLogEntries); PulledLogEntries,
Recreated,
Pruned);
} }
/// <summary>The record written to the conflict log when a merge had to override something.</summary> /// <summary>The record written to the conflict log when a merge had to override something.</summary>
@@ -327,7 +327,7 @@ internal sealed class VaultItemRepository<TSecret>(
if (pending is not null && NeverReachedTheServer(pending)) if (pending is not null && NeverReachedTheServer(pending))
{ {
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false); await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
// Recorded even though nothing goes to the server. Somebody created an item and then removed it, // Recorded even though nothing goes to the server. Somebody created an item and then removed it,
// which is two things they did — and a log that showed only the create would describe a keychain // which is two things they did — and a log that showed only the create would describe a keychain
@@ -56,6 +56,16 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
private const int MaximumRequestBytes = 16 * 1024; private const int MaximumRequestBytes = 16 * 1024;
private const int ReceiveBufferBytes = 64 * 1024; private const int ReceiveBufferBytes = 64 * 1024;
/// <summary>The largest message from the page this host will reassemble.</summary>
/// <remarks>
/// A paste is one input frame however long it is, and so is a copied selection, so the receive buffer
/// is the size of a read and not the size of a message. Sixteen megabytes is far beyond anything a
/// person pastes into a shell on purpose and still small enough that a page sending garbage cannot make
/// this process hold an unbounded amount of it. A message beyond it is dropped whole — never its tail,
/// which would be parsed as a frame whose first bytes are somebody's paste.
/// </remarks>
private const int MaximumMessageBytes = 16 * 1024 * 1024;
private readonly TcpListener listener; private readonly TcpListener listener;
private readonly ITerminalAssetProvider assets; private readonly ITerminalAssetProvider assets;
private readonly Dictionary<uint, TerminalSessionPump> pumps = []; private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
@@ -451,6 +461,11 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
{ {
var buffer = new byte[ReceiveBufferBytes]; var buffer = new byte[ReceiveBufferBytes];
// Grown only when a message spans more than one read, and kept for the next one that does.
var message = Array.Empty<byte>();
var length = 0;
var oversized = false;
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
{ {
var result = await webSocket var result = await webSocket
@@ -462,14 +477,73 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
return; return;
} }
if (!result.EndOfMessage) // The common case — a keystroke, an acknowledgement — is one read, and goes straight through.
if (result.EndOfMessage && length == 0 && !oversized)
{ {
// A frame larger than the receive buffer. Terminal input and acknowledgements are tiny, DispatchSafely(buffer.AsSpan(0, result.Count));
// so this is either a bug or something hostile; dropping is the safe response.
continue; continue;
} }
Dispatch(buffer.AsSpan(0, result.Count)); if (!oversized && length + result.Count <= MaximumMessageBytes)
{
if (length + result.Count > message.Length)
{
Array.Resize(ref message, Math.Min(MaximumMessageBytes, Math.Max(message.Length * 2, length + result.Count)));
}
buffer.AsSpan(0, result.Count).CopyTo(message.AsSpan(length));
length += result.Count;
}
else
{
oversized = true;
}
if (!result.EndOfMessage)
{
continue;
}
if (!oversized)
{
DispatchSafely(message.AsSpan(0, length));
}
length = 0;
oversized = false;
}
}
/// <summary>Dispatches one frame, so that what goes wrong with one session stays with that session.</summary>
/// <remarks>
/// Every session shares this socket, and an exception escaping the receive loop closes it — so a resize
/// that reached a shell SSH.NET had already disposed, which is an ordinary moment at the end of every
/// session, used to drop every other tab to "reconnecting" and lose the output sent meanwhile. A frame
/// that cannot be acted on is a frame not acted on; the session it was for reports its own ending.
/// </remarks>
private void DispatchSafely(ReadOnlySpan<byte> frame)
{
try
{
Dispatch(frame);
}
catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException)
{
// See the remarks.
}
}
/// <summary>Writes one input frame, without letting a session that is going away fault anything.</summary>
private async Task WriteInputAsync(TerminalSessionPump pump, byte[] input)
{
try
{
await pump.WriteInputAsync(input, lifetime.Token).ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The shell ended or the connection dropped between the keystroke and the write. The session's
// own read loop reports that; a keystroke that had nowhere to go has nothing to add.
} }
} }
@@ -502,7 +576,7 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
case TerminalClientOpcode.Input: case TerminalClientOpcode.Input:
// Fire and forget: a blocked write must not stall acknowledgements for other sessions // Fire and forget: a blocked write must not stall acknowledgements for other sessions
// sharing this socket. // sharing this socket.
_ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask(); _ = WriteInputAsync(pump, payload.ToArray());
break; break;
case TerminalClientOpcode.Acknowledge: case TerminalClientOpcode.Acknowledge:
@@ -107,6 +107,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
private readonly OutputBacklog backlog; private readonly OutputBacklog backlog;
private readonly CancellationTokenSource lifetime = new(); private readonly CancellationTokenSource lifetime = new();
/// <summary>One write to the remote at a time, in the order they were asked for.</summary>
/// <remarks>
/// The data plane hands each input frame over without waiting for the one before it — a blocked write
/// must not hold up the acknowledgements of every other session on the socket — so without this, two
/// frames' writes run at once. SSH.NET's <c>ShellStream</c> takes no lock around its write buffer, and its
/// flush sends what is there and then empties it by length: bytes added mid-flush are discarded unsent.
/// A snippet's RUN is its text and then a carriage return as two frames, which is exactly the shape that lost or
/// reordered keystrokes. Waiters on a <see cref="SemaphoreSlim"/> are released in the order they arrived,
/// and the data plane calls this from one receive loop, so arrival order is the order typed.
/// </remarks>
private readonly SemaphoreSlim inputGate = new(1, 1);
private int disposed; private int disposed;
/// <param name="sessionId">Identifies this session in every frame.</param> /// <param name="sessionId">Identifies this session in every frame.</param>
@@ -267,9 +280,20 @@ public sealed class TerminalSessionPump : IAsyncDisposable
} }
} }
/// <summary>Forwards keystrokes to the remote.</summary> /// <summary>Forwards keystrokes to the remote, after any still being written.</summary>
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) => public async ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
session.WriteAsync(data, cancellationToken); {
await inputGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await session.WriteAsync(data, cancellationToken).ConfigureAwait(false);
}
finally
{
inputGate.Release();
}
}
/// <summary>Tells the remote the terminal was resized.</summary> /// <summary>Tells the remote the terminal was resized.</summary>
public void Resize(TerminalSize size) => session.Resize(size); public void Resize(TerminalSize size) => session.Resize(size);
@@ -194,6 +194,15 @@ public sealed class FileTransferQueue : IAsyncDisposable
private readonly CancellationTokenSource lifetime = new(); private readonly CancellationTokenSource lifetime = new();
private Task pump = Task.CompletedTask; private Task pump = Task.CompletedTask;
/// <summary>Whether a pump owns the queue: set when one is started, cleared by the pump as it finds it empty.</summary>
/// <remarks>
/// Guarded by <see cref="gate"/>, and the reason it exists rather than asking <see cref="pump"/> whether it
/// has finished. The pump decides it is done under the gate, but its task is only marked complete after it
/// has let go — so a transfer queued in between found a pump that was still "running", started none, and
/// stayed queued for good. This is cleared in the same locked step that found nothing to take.
/// </remarks>
private bool pumping;
private bool disposed; private bool disposed;
/// <param name="sessions"> /// <param name="sessions">
@@ -329,7 +338,16 @@ public sealed class FileTransferQueue : IAsyncDisposable
} }
} }
running?.Cancel(); // Outside the gate, because cancelling runs the token's callbacks on this thread — but that leaves a
// moment in which the run finishes and disposes the source it was read from. A disposed source is a
// transfer that is already over, so there is nothing left to cancel and nothing to report.
try
{
running?.Cancel();
}
catch (ObjectDisposedException)
{
}
if (cancelled is not null) if (cancelled is not null)
{ {
@@ -451,13 +469,39 @@ public sealed class FileTransferQueue : IAsyncDisposable
/// </remarks> /// </remarks>
private void EnsurePumping() private void EnsurePumping()
{ {
if (pump.IsCompleted && !disposed) if (!pumping && !disposed)
{ {
pumping = true;
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token); pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
} }
} }
/// <remarks>
/// Leaves by one of two doors. The ordinary one is <see cref="TakeNext"/> finding the queue empty, which
/// hands the queue back in the same locked step. Anything else — a fault escaping a row — hands it back
/// on the way out, so the next transfer queued starts a pump rather than waiting on one that is gone.
/// </remarks>
private async Task PumpAsync(CancellationToken cancellationToken) private async Task PumpAsync(CancellationToken cancellationToken)
{
var drained = false;
try
{
drained = await PumpUntilEmptyAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
if (!drained)
{
lock (gate)
{
pumping = false;
}
}
}
}
private async Task<bool> PumpUntilEmptyAsync(CancellationToken cancellationToken)
{ {
while (TakeNext() is { } entry) while (TakeNext() is { } entry)
{ {
@@ -489,6 +533,8 @@ public sealed class FileTransferQueue : IAsyncDisposable
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false); await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
} }
return true;
} }
/// <summary>Takes the next queued transfer and marks it running.</summary> /// <summary>Takes the next queued transfer and marks it running.</summary>
@@ -502,6 +548,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
if (next is null) if (next is null)
{ {
// In the same locked step that found nothing, so a transfer queued from here on starts a
// pump of its own. See pumping.
pumping = false;
return null; return null;
} }
@@ -701,7 +751,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
var part = destination + PartSuffix; var part = destination + PartSuffix;
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false); var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
var offset = ResumableLength(entry, existing);
// A bucket writes an object whole, so a part left by a failed attempt is started over rather than
// resumed — and replaced, since writing an object replaces it.
var offset = session.CanWriteFromAnOffset ? ResumableLength(entry, existing) : 0;
if (offset == 0 && existing is not null) if (offset == 0 && existing is not null)
{ {
@@ -94,6 +94,49 @@ public static class LocalDirectory
/// </remarks> /// </remarks>
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path)); public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
/// <summary>
/// Where a file another machine named should land inside <paramref name="directory"/>, or null when that
/// name cannot be a file directly inside it.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The name is the remote's, and the remote is not trusted to have chosen one that stays put.</b>
/// A POSIX file name may contain a backslash, a colon or a leading drive letter, and on Windows any of
/// those turns <c>Path.Combine(directory, name)</c> into somewhere else: <c>..\..\AppData\…\Startup\x.bat</c>
/// climbs out, and <c>C:\…</c> is rooted, so <see cref="Path.Combine(string, string)"/> drops the
/// directory altogether. An S3 key is worse, because <c>../</c> is an ordinary prefix there on every
/// platform. Whoever can write a name into a shared directory or bucket would otherwise choose where a
/// download writes on this machine.
/// </para>
/// <para>
/// Characters this platform cannot put in a file name are replaced rather than refused, so a Linux file
/// called <c>backup:2026-10-02</c> still downloads to Windows, as <c>backup_2026-10-02</c>. What is
/// refused is a name that is nothing but a reference to here or above. The final check resolves the path
/// and requires its parent to be the directory, which also catches what Windows does to a trailing dot
/// or space — <c>.. </c> is <c>..</c> by the time it reaches the disk.
/// </para>
/// </remarks>
public static string? ChildPath(string directory, string name)
{
ArgumentNullException.ThrowIfNull(directory);
ArgumentNullException.ThrowIfNull(name);
var cleaned = string.Join('_', name.Split(Path.GetInvalidFileNameChars()));
if (cleaned.Trim() is "" or "." or "..")
{
return null;
}
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
var child = Path.GetFullPath(Path.Combine(root, cleaned));
return string.Equals(Path.GetDirectoryName(child), root, StringComparison.Ordinal)
&& !string.Equals(child, root, StringComparison.Ordinal)
? child
: null;
}
/// <summary> /// <summary>
/// Where the local pane can start from: the drives on Windows, and the root elsewhere. /// Where the local pane can start from: the drives on Windows, and the root elsewhere.
/// </summary> /// </summary>
+3
View File
@@ -28,6 +28,9 @@ public static class ProblemCodes
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary> /// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
public const string EnrollmentRequired = "enrollment-required"; public const string EnrollmentRequired = "enrollment-required";
/// <summary>The caller's account has been suspended or deprovisioned by an operator.</summary>
public const string AccountNotActive = "account-not-active";
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary> /// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
public const string AlreadyEnrolled = "already-enrolled"; public const string AlreadyEnrolled = "already-enrolled";
@@ -1,6 +1,7 @@
#nullable enable #nullable enable
const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string! const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string!
const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string! const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
const DodoSSH.Contracts.ProblemCodes.AccountNotActive = "account-not-active" -> string!
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string! const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
const DodoSSH.Contracts.ProblemCodes.EventsUnavailable = "events-unavailable" -> string! const DodoSSH.Contracts.ProblemCodes.EventsUnavailable = "events-unavailable" -> string!
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string! const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
@@ -381,6 +382,7 @@ DodoSSH.Contracts.RelayConfiguration.RelayConfiguration(bool Enabled, System.Uri
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.get -> System.Uri? DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.get -> System.Uri?
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.init -> void DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.init -> void
DodoSSH.Contracts.RelaySessionCloseReason DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.AccessRevoked = 8 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.ClientClosed = 1 -> DodoSSH.Contracts.RelaySessionCloseReason DodoSSH.Contracts.RelaySessionCloseReason.ClientClosed = 1 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.DurationLimit = 4 -> DodoSSH.Contracts.RelaySessionCloseReason DodoSSH.Contracts.RelaySessionCloseReason.DurationLimit = 4 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.Error = 7 -> DodoSSH.Contracts.RelaySessionCloseReason DodoSSH.Contracts.RelaySessionCloseReason.Error = 7 -> DodoSSH.Contracts.RelaySessionCloseReason
@@ -968,6 +970,7 @@ static DodoSSH.Contracts.VaultKeyWrap.operator ==(DodoSSH.Contracts.VaultKeyWrap
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
const DodoSSH.Contracts.ProblemCodes.RelayUnavailable = "relay-unavailable" -> string! const DodoSSH.Contracts.ProblemCodes.RelayUnavailable = "relay-unavailable" -> string!
const DodoSSH.Contracts.RelayProtocol.AccessRevokedCloseCode = 4403 -> int
const DodoSSH.Contracts.RelayProtocol.ConnectPath = "/api/v1/relay/connect" -> string! const DodoSSH.Contracts.RelayProtocol.ConnectPath = "/api/v1/relay/connect" -> string!
const DodoSSH.Contracts.RelayProtocol.DurationLimitCloseCode = 4409 -> int const DodoSSH.Contracts.RelayProtocol.DurationLimitCloseCode = 4409 -> int
const DodoSSH.Contracts.RelayProtocol.Feature = "relay" -> string! const DodoSSH.Contracts.RelayProtocol.Feature = "relay" -> string!
+12
View File
@@ -70,6 +70,15 @@ public static class RelayProtocol
/// <summary>Close code for a session that reached the maximum lifetime this server allows.</summary> /// <summary>Close code for a session that reached the maximum lifetime this server allows.</summary>
public const int DurationLimitCloseCode = 4409; public const int DurationLimitCloseCode = 4409;
/// <summary>
/// Close code for a session whose user lost access to the host's vault while it was open.
/// </summary>
/// <remarks>
/// Removed from the team, or the vault's key withdrawn. Reconnecting will not help: a new ticket is
/// refused for the same reason.
/// </remarks>
public const int AccessRevokedCloseCode = 4403;
} }
@@ -143,6 +152,9 @@ public enum RelaySessionCloseReason
/// <summary>An error ended the session.</summary> /// <summary>An error ended the session.</summary>
Error = 7, Error = 7,
/// <summary>The user lost access to the host's vault while the session was open.</summary>
AccessRevoked = 8,
} }
/// <summary> /// <summary>
+3
View File
@@ -120,4 +120,7 @@ public enum RelayCloseReason
/// <summary>An error ended the session.</summary> /// <summary>An error ended the session.</summary>
Error = 7, Error = 7,
/// <summary>The user lost access to the host's vault while the session was open.</summary>
AccessRevoked = 8,
} }
+74 -15
View File
@@ -19,10 +19,21 @@ namespace DodoSSH.Domain.Sync;
/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be /// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
/// replayed against another. See ADR 0003. /// replayed against another. See ADR 0003.
/// </para> /// </para>
/// <para>
/// And the vault's sync horizon when it was issued — the last deletion the server had forgotten. A cursor
/// behind the horizon is refused when it is older than the collection, because its holder may still have the
/// deleted item; it is fine when it is newer, because then it belongs to a read from the beginning that
/// started after the collection, whose early pages are behind the horizon by nature. Position alone cannot
/// tell those two apart. A cursor issued under no horizon keeps the first format, so a server from before
/// this still reads it.
/// </para>
/// </remarks> /// </remarks>
public static class SyncCursor public static class SyncCursor
{ {
private const string Version = "v1"; private const string Version = "v1";
/// <summary>The format that carries a horizon as well.</summary>
private const string HorizonVersion = "v2";
private const char Separator = '|'; private const char Separator = '|';
/// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary> /// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary>
@@ -37,7 +48,17 @@ public static class SyncCursor
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param> /// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
/// <param name="vaultId">Vault the cursor belongs to.</param> /// <param name="vaultId">Vault the cursor belongs to.</param>
/// <param name="sequence">Last change sequence the client has consumed.</param> /// <param name="sequence">Last change sequence the client has consumed.</param>
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence) public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence) =>
Encode(signingKey, vaultId, sequence, horizon: 0);
/// <summary>
/// Encodes a position, with the sync horizon it was read under.
/// </summary>
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
/// <param name="vaultId">Vault the cursor belongs to.</param>
/// <param name="sequence">Last change sequence the client has consumed.</param>
/// <param name="horizon">The vault's sync horizon before the read that produced this position.</param>
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence, long horizon)
{ {
RequireKey(signingKey); RequireKey(signingKey);
@@ -46,7 +67,12 @@ public static class SyncCursor
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative."); throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
} }
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence)); if (horizon < 0)
{
throw new ArgumentOutOfRangeException(nameof(horizon), horizon, "Horizon must not be negative.");
}
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence, horizon));
var buffer = new byte[payload.Length + TagLength]; var buffer = new byte[payload.Length + TagLength];
payload.CopyTo(buffer, 0); payload.CopyTo(buffer, 0);
@@ -70,10 +96,27 @@ public static class SyncCursor
ReadOnlySpan<byte> signingKey, ReadOnlySpan<byte> signingKey,
string? cursor, string? cursor,
Guid expectedVaultId, Guid expectedVaultId,
out long sequence) out long sequence) =>
TryDecode(signingKey, cursor, expectedVaultId, out sequence, out _);
/// <summary>
/// Decodes and verifies a cursor, with the horizon it was issued under — zero for one issued under none.
/// </summary>
/// <param name="signingKey">Deployment cursor signing key.</param>
/// <param name="cursor">The cursor to verify.</param>
/// <param name="expectedVaultId">Vault the request is scoped to.</param>
/// <param name="sequence">The decoded sequence, when verification succeeds.</param>
/// <param name="horizon">The decoded horizon, when verification succeeds.</param>
public static bool TryDecode(
ReadOnlySpan<byte> signingKey,
string? cursor,
Guid expectedVaultId,
out long sequence,
out long horizon)
{ {
RequireKey(signingKey); RequireKey(signingKey);
sequence = 0; sequence = 0;
horizon = 0;
if (string.IsNullOrEmpty(cursor)) if (string.IsNullOrEmpty(cursor))
{ {
@@ -97,17 +140,26 @@ public static class SyncCursor
return false; return false;
} }
return TryParsePayload(payload, expectedVaultId, out sequence); return TryParsePayload(payload, expectedVaultId, out sequence, out horizon);
} }
private static string FormatPayload(Guid vaultId, long sequence) => private static string FormatPayload(Guid vaultId, long sequence, long horizon) =>
string.Create( horizon == 0
CultureInfo.InvariantCulture, ? string.Create(
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}"); CultureInfo.InvariantCulture,
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}")
: string.Create(
CultureInfo.InvariantCulture,
$"{HorizonVersion}{Separator}{vaultId:D}{Separator}{sequence}{Separator}{horizon}");
private static bool TryParsePayload(ReadOnlySpan<byte> payload, Guid expectedVaultId, out long sequence) private static bool TryParsePayload(
ReadOnlySpan<byte> payload,
Guid expectedVaultId,
out long sequence,
out long horizon)
{ {
sequence = 0; sequence = 0;
horizon = 0;
string text; string text;
try try
@@ -122,12 +174,14 @@ public static class SyncCursor
// Tagged, so the shape is ours — but parse defensively anyway, because a key rotation // Tagged, so the shape is ours — but parse defensively anyway, because a key rotation
// could make an old format verify against a new expectation. // could make an old format verify against a new expectation.
var parts = text.Split(Separator); var parts = text.Split(Separator);
if (parts.Length != 3) var expectedParts = parts[0] switch
{ {
return false; Version => 3,
} HorizonVersion => 4,
_ => 0,
};
if (!string.Equals(parts[0], Version, StringComparison.Ordinal)) if (parts.Length != expectedParts)
{ {
return false; return false;
} }
@@ -137,8 +191,13 @@ public static class SyncCursor
return false; return false;
} }
return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence) if (!long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence))
&& sequence >= 0; {
return false;
}
return expectedParts == 3
|| long.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out horizon);
} }
private static void ComputeTag( private static void ComputeTag(
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff. Show more