Compare commits

...
14 Commits
Author SHA1 Message Date
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
52 changed files with 1588 additions and 136 deletions

No files matched your search

@@ -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.Text.Json;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
byte[] fingerprint,
CancellationToken cancellationToken)
{
var strategy = database.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
return await database.ExecuteFreshAsync([user], async () =>
{
var transaction = await database.Database
.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;
}
var lease = sessions.TryAdmit(ticket.UserId);
var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
try
{
@@ -113,7 +113,7 @@ internal sealed class RelayConnectEndpoint(
return;
}
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
}
finally
{
@@ -239,7 +239,11 @@ internal sealed class RelayConnectEndpoint(
}
/// <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();
@@ -269,7 +273,7 @@ internal sealed class RelayConnectEndpoint(
{
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(
@@ -338,13 +342,14 @@ internal sealed class RelayConnectEndpoint(
Stream target,
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken requestAborted)
{
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
var upstream = ClientToTargetAsync(socket, target, 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 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.
/// </para>
/// </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 hardDeadline = started + settings.MaxSessionDuration;
@@ -527,27 +536,11 @@ internal sealed class RelayConnectEndpoint(
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;
if (now >= idleDeadline)
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
{
return new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.");
return ending;
}
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
// than from whenever the idle clock next came round.
using var wake = drainDeadline is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
try
{
@@ -564,7 +557,7 @@ internal sealed class RelayConnectEndpoint(
}
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)
{
@@ -575,6 +568,50 @@ internal sealed class RelayConnectEndpoint(
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; } =
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
@@ -91,4 +91,14 @@ internal static partial class RelayLog
Level = LogLevel.Warning,
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
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 Dictionary<Guid, int> perUser = [];
private readonly HashSet<Lease> open = [];
private int total;
/// <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>
/// <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;
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
perUser[userId] = held + 1;
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)
{
open.Remove(lease);
total--;
// 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>
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;
/// <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 />
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0)
{
registry.Release(userId);
registry.Release(this, UserId);
revoked.Dispose();
}
}
}
+1 -3
View File
@@ -100,9 +100,7 @@ internal sealed class SyncService(
{
ValidateBatchLimits(request);
var strategy = database.Database.CreateExecutionStrategy();
return await strategy.ExecuteAsync(async () =>
return await database.ExecuteFreshAsync([vault], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -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);
}
+17 -19
View File
@@ -1,5 +1,6 @@
using System.Globalization;
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -254,26 +255,27 @@ internal sealed class TeamService(
throw new InvalidOperationException("Only the owner of a team can archive it.");
}
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 now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var archived = await strategy.ExecuteAsync(async () =>
var archived = await database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.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
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
@@ -343,9 +345,7 @@ internal sealed class TeamService(
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy();
await strategy.ExecuteAsync(async () =>
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -724,9 +724,7 @@ internal sealed class TeamService(
}
var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy();
var revoked = await strategy.ExecuteAsync(async () =>
var revoked = await database.ExecuteFreshAsync([membership], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
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 WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
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>
private void AddVaultWithSelfGrant(
UserAccount user,
+5 -6
View File
@@ -41,13 +41,12 @@ builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
// write paths that announce a change need only the two methods that announce one, and should not gain
// a reference to connection management to get them.
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>(
provider => provider.GetRequiredService<VaultEventHub>());
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
builder.Services.AddDodoRelay();
+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 Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Npgsql;
@@ -29,14 +30,18 @@ internal static class Persistence
?? throw new InvalidOperationException(
$"ConnectionStrings:{ConnectionStringName} is not configured.");
services.AddDbContext<DodoDbContext>(options => options
services.AddDbContext<DodoDbContext>((provider, options) => options
.UseNpgsql(connectionString, npgsql => npgsql
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
// Retry on transient faults only. Note this is safe here because writes go
// through explicitly-managed transactions rather than relying on the execution
// strategy to replay ambient ones.
// Retry on transient faults only. A write transaction replayed by the strategy runs on
// the same context, which still holds what the failed attempt tracked — every such
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
// the database instead.
.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()
.AddCheck<DatabaseHealthCheck>(
+1
View File
@@ -24,6 +24,7 @@ internal static class RelaySetup
// the network seam holds no state.
services.AddSingleton<RelayTicketCodec>();
services.AddSingleton<RelaySessionRegistry>();
services.AddSingleton<RelayAccessWatch>();
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
// The application name isolates this deployment's payloads from any other ASP.NET application
+4
View File
@@ -48,6 +48,10 @@ internal sealed partial class DodoSshApp : Application
{
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();
ShowSomethingWhileComposing(desktop);
}
@@ -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,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>
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="askForPin">
/// 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(askForPin);
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
}
private static IDeviceKeyStore Choose(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{
return new WindowsDeviceKeyStore(paths);
+3
View File
@@ -75,6 +75,9 @@ internal static class Program
LaunchLink = link;
// After the claim, so a second launch that only hands its link over writes nothing.
CrashLog.Install(paths);
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;
}
}
@@ -26,7 +26,7 @@
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
PointerPressed="OnDrag">
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
{
public SettingsTitleBar() => InitializeComponent();
private readonly CaptionPresses presses = new();
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>
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
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);
}
@@ -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>
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
if (Host is not { } window)
+1 -1
View File
@@ -32,7 +32,7 @@
-->
<Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
PointerPressed="OnDrag">
<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;
/// <remarks>
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
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);
}
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
/// <remarks>
/// 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>
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
@@ -122,6 +122,9 @@ public sealed record ClientPaths(string DataDirectory)
/// </remarks>
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>
/// <remarks>
/// 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 =>
"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,
};
}
@@ -2133,12 +2133,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
.ConfigureAwait(true);
try
{
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
// second means the menu and the screen behind it never disagree, even for a frame.
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing
// it second means the menu and the screen behind it never disagree, even for a frame.
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();
@@ -2950,13 +2963,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
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);
// 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.
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,
workspace,
knownHosts,
@@ -2980,9 +3004,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// are both !CanRegisterDevice, and only the second has anything to withdraw.
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
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
@@ -2997,7 +3042,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// 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
// 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>
@@ -3259,6 +3318,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
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
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
// reappearing behind a lock screen.
@@ -3306,6 +3369,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
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>
/// What signing out costs, on this machine, right now.
/// </summary>
@@ -2289,6 +2289,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// ---- 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>
[RelayCommand]
private void Download()
@@ -2306,7 +2310,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
ClearRemoteChecks();
});
},
StillBusy);
}
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
@@ -2328,7 +2333,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
ClearLocalChecks();
});
},
StillBusy);
}
/// <summary>Unticks everything on the host's side.</summary>
@@ -2393,13 +2399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal void QueueUploads(IReadOnlyList<string> paths) =>
_ = RunAsync(
"Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None));
() => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
/// <inheritdoc cref="QueueUploads" />
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
_ = RunAsync(
"Working out what to download…",
() => QueueDownloadsAsync(rows, CancellationToken.None));
() => QueueDownloadsAsync(rows, CancellationToken.None),
StillBusy);
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
{
@@ -2580,7 +2588,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
_ = RunAsync(
"Working out what to upload…",
() => QueueUploadsAsync(paths, CancellationToken.None));
() => QueueUploadsAsync(paths, CancellationToken.None),
StillBusy);
}
/// <summary>
@@ -4154,11 +4163,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
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
/// 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>
private async Task RunAsync(string busyMessage, Func<Task> work)
private async Task RunAsync(string busyMessage, Func<Task> work, string? refusedWhileBusy = null)
{
if (IsBusy)
{
if (refusedWhileBusy is not null)
{
Status = refusedWhileBusy;
}
return;
}
@@ -6367,7 +6367,7 @@ internal sealed partial class VaultViewModel(
{
// 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.
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
// vault that never synced would show its hosts exactly once — at the unlock that first
@@ -6380,6 +6380,15 @@ internal sealed partial class VaultViewModel(
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
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);
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
@@ -6423,7 +6432,7 @@ internal sealed partial class VaultViewModel(
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
/// </para>
/// </remarks>
private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
private async Task<bool> AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{
var before = session.Vaults.Count;
@@ -6431,12 +6440,39 @@ internal sealed partial class VaultViewModel(
if (admitted == 0 && session.Vaults.Count == before)
{
return;
return false;
}
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.DeletesAbandoned > 0
|| pass.Parked > 0
|| pass.ResyncedFromStart);
/// <summary>
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
/// </summary>
@@ -468,7 +468,21 @@ internal sealed class SshNetConnection(
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
/// <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();
@@ -476,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
// only delays the first byte reaching the screen.
var shell = client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
effective.PixelHeight,
4096);
var opening = Task.Run(
() => client.CreateShellStream(
"xterm-256color",
effective.Columns,
effective.Rows,
effective.PixelWidth,
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 />
+59
View File
@@ -118,6 +118,65 @@ public sealed class ItemStore(
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>Removes a tombstone whose change has been seen by everything that needed it.</summary>
/// <remarks>
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
+21 -12
View File
@@ -546,16 +546,25 @@ internal static class ItemMirror
Guid vaultId,
SyncChange change,
CancellationToken cancellationToken) =>
items.SaveAsync(
new StoredItem(
vaultId,
change.EntityType,
change.EntityId,
change.Version,
change.ChangeSequence,
change.Payload,
change.PlaintextFields,
change.Operation == SyncOperation.Delete,
change.UpdatedAt),
cancellationToken);
items.SaveAsync(ToStored(vaultId, change), cancellationToken);
/// <summary>Mirrors a run of changes in one write; see <see cref="ItemStore.SaveManyAsync"/>.</summary>
internal static Task WriteManyAsync(
ItemStore items,
Guid vaultId,
IReadOnlyList<SyncChange> changes,
CancellationToken cancellationToken) =>
items.SaveManyAsync(changes.Select(change => ToStored(vaultId, change)).ToList(), cancellationToken);
private static StoredItem ToStored(Guid vaultId, SyncChange change) =>
new(
vaultId,
change.EntityType,
change.EntityId,
change.Version,
change.ChangeSequence,
change.Payload,
change.PlaintextFields,
change.Operation == SyncOperation.Delete,
change.UpdatedAt);
}
+72 -1
View File
@@ -154,9 +154,10 @@ public sealed class SyncEngine
continue;
}
await ApplyPageAsync(vaultId, response.Changes, report, cancellationToken).ConfigureAwait(false);
foreach (var change in response.Changes)
{
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
@@ -310,6 +311,61 @@ public sealed class SyncEngine
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(
Guid vaultId,
SyncChange change,
@@ -483,6 +539,17 @@ public sealed class SyncEngine
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>
private async Task<bool> HandleAsync(
Guid vaultId,
@@ -525,6 +592,10 @@ public sealed class SyncEngine
cancellationToken).ConfigureAwait(false);
return false;
case SyncOperationStatus.Invalid when DeletesSomethingNeverCreated(operation):
await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
return false;
case SyncOperationStatus.Invalid:
await RejectAsync(
vaultId,
@@ -194,6 +194,15 @@ public sealed class FileTransferQueue : IAsyncDisposable
private readonly CancellationTokenSource lifetime = new();
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;
/// <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)
{
@@ -451,13 +469,39 @@ public sealed class FileTransferQueue : IAsyncDisposable
/// </remarks>
private void EnsurePumping()
{
if (pump.IsCompleted && !disposed)
if (!pumping && !disposed)
{
pumping = true;
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)
{
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)
{
@@ -489,6 +533,8 @@ public sealed class FileTransferQueue : IAsyncDisposable
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
}
return true;
}
/// <summary>Takes the next queued transfer and marks it running.</summary>
@@ -502,6 +548,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
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;
}
@@ -382,6 +382,7 @@ DodoSSH.Contracts.RelayConfiguration.RelayConfiguration(bool Enabled, System.Uri
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.get -> System.Uri?
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.init -> void
DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.AccessRevoked = 8 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.ClientClosed = 1 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.DurationLimit = 4 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.Error = 7 -> DodoSSH.Contracts.RelaySessionCloseReason
@@ -969,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
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.DurationLimitCloseCode = 4409 -> int
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>
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>
Error = 7,
/// <summary>The user lost access to the host's vault while the session was open.</summary>
AccessRevoked = 8,
}
/// <summary>
+3
View File
@@ -120,4 +120,7 @@ public enum RelayCloseReason
/// <summary>An error ended the session.</summary>
Error = 7,
/// <summary>The user lost access to the host's vault while the session was open.</summary>
AccessRevoked = 8,
}
+9 -1
View File
@@ -14,6 +14,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using Xunit;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DodoSSH.Api.Tests;
@@ -108,9 +109,16 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
Environment.SetEnvironmentVariable(name, value);
}
builder.ConfigureTestServices(services => services.AddSingleton<IRelayNetwork>(RelayNetwork));
builder.ConfigureTestServices(services =>
{
services.AddSingleton<IRelayNetwork>(RelayNetwork);
services.AddSingleton<IInterceptor>(Commits);
});
}
/// <summary>Fails a commit on request, so a test can watch the retrying execution strategy at work.</summary>
internal FlakyCommits Commits { get; } = new();
/// <summary>
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
/// </summary>
+43
View File
@@ -0,0 +1,43 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Fails the next commit the way a connection dropped on the way to the server would, when asked to.
/// </summary>
/// <remarks>
/// Before the commit reaches the database, so the transaction is rolled back — which is what a lost commit
/// looks like from the server's side — and with a <see cref="TimeoutException"/>, which the retrying
/// execution strategy treats as transient. That is the only way to watch a retry happen against a real
/// database, and the retry is the thing worth watching: see <c>FreshAttempts</c>.
/// </remarks>
internal sealed class FlakyCommits : DbTransactionInterceptor
{
private int armed;
private int failed;
/// <summary>How many commits this has failed.</summary>
internal int Failed => Volatile.Read(ref failed);
/// <summary>Makes the next commit, whoever's it is, fail once.</summary>
internal void FailNextCommit() => Interlocked.Exchange(ref armed, 1);
/// <inheritdoc />
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref armed, 0) == 1)
{
Interlocked.Increment(ref failed);
throw new TimeoutException("The commit was lost on its way to the server.");
}
return base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken);
}
}
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
using DodoSSH.Api.Features.Events;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -266,6 +267,56 @@ public sealed class RelayEndpointTests(ApiFixture fixture)
.ShouldBe(RelayCloseReason.IdleTimeout);
}
/// <remarks>
/// <para>
/// A session is authorised once, when its ticket is issued, and used to run on for as long as it carried
/// bytes — removing somebody from a team left their shells into its machines open for up to the session's
/// maximum lifetime. The announcement that tells their event socket to re-read its vaults now has the
/// relay ask again too.
/// </para>
/// <para>
/// Access is withdrawn here by deleting the vault rather than by a team removal, because what is under
/// test is the relay hearing the announcement and asking the access model again — any way of making the
/// answer change will do, and this one needs no team. The removal path announces through the same call;
/// see <c>TeamService.RemoveMemberAsync</c>.
/// </para>
/// </remarks>
[Fact]
public async Task ASessionWhoseUserLosesAccess_IsClosedWithItsOwnCode()
{
using var target = LoopbackTarget.Echo();
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.8.1");
var ticket = await IssueAsync(subject, hostId);
using var timeout = Timeout();
using var socket = await ConnectAsync(ticket, timeout.Token);
await RoundTripAsync(socket, "still here", timeout.Token);
Guid userId;
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
userId = (await database.Users.SingleAsync(u => u.Subject == subject, timeout.Token)).Id;
var vaultId = (await database.Hosts.SingleAsync(h => h.Id == hostId, timeout.Token)).VaultId;
var vault = await database.Vaults.SingleAsync(v => v.Id == vaultId, timeout.Token);
vault.DeletedAtUtc = Now;
await database.SaveChangesAsync(timeout.Token);
scope.ServiceProvider.GetRequiredService<IVaultEventPublisher>().VaultAccessChanged(userId);
}
await ReadUntilCloseAsync(socket, timeout.Token);
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.AccessRevokedCloseCode);
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
.ShouldBe(RelayCloseReason.AccessRevoked);
}
[Fact]
public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed()
{
@@ -647,6 +647,38 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.Role.ShouldBe(TeamMemberRole.Owner);
}
/// <remarks>
/// The commit lost on its way to the database, which the execution strategy answers by running the
/// transaction again on the same context. That context still held the two memberships as the failed
/// attempt left them — already owner, already admin, and marked unchanged — so the retry saved nothing,
/// committed, and answered 204 over a team whose roles had not moved. See <c>FreshAttempts</c>.
/// </remarks>
[Fact]
public async Task ATransferWhoseCommitIsLost_IsRetriedFromTheDatabase_AndStillLands()
{
var owner = await EnrolledClientAsync("retry-owner", "rowner@example.com");
await EnrolledClientAsync("retry-successor", "rsuccessor@example.com");
var team = await CreateTeamAsync(owner, "Retried handover");
var entry = await LookupAsync(owner, "rsuccessor@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var failedBefore = fixture.Commits.Failed;
fixture.Commits.FailNextCommit();
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
fixture.Commits.Failed.ShouldBe(failedBefore + 1, "the first commit has to have been lost for this to mean anything");
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId));
members.Where(row => row.UserId == entry.UserId).ShouldHaveSingleItem().Role.ShouldBe(TeamMemberRole.Owner);
members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);
}
/// <remarks>
/// The thing that was impossible before this endpoint existed. An owner could not be removed and
/// could not be demoted, so somebody who left the company owning a team left it owned by them for
@@ -66,6 +66,52 @@ public sealed class TitleBarTests : IAsyncLifetime
caches.Dispose();
}
/// <remarks>
/// A double-click on the bar maximises, and a second one restores. It was a <c>DoubleTapped</c> handler
/// beside a press that started a drag, and the drag handed the press to the operating system's move loop,
/// which ate the release a double tap is assembled from — so on Windows the click often did nothing. The
/// press decides now; see <c>CaptionPresses</c>. A single press must still be a drag and nothing else.
/// </remarks>
[Fact]
public async Task DoubleClickingTheBar_MaximisesAndRestores_AndASinglePressDoesNot()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var bar = new TitleBar { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
try
{
// The top edge, past everything the bar draws: the drag surface and nothing else.
var empty = new Point(bar.Bounds.Width * 0.6, 2);
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.WindowState.ShouldBe(WindowState.Normal, "one press is a drag");
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.WindowState.ShouldBe(WindowState.Maximized);
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.WindowState.ShouldBe(WindowState.Normal);
}
finally
{
window.Close();
}
},
Token);
}
[Fact]
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
{
@@ -0,0 +1,41 @@
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// That the application leaves a record of why it died.
/// </summary>
/// <remarks>
/// An exception nothing caught used to close the window and leave nothing at all behind. Installing the hooks
/// here also installs them in the test process, which is harmless: they only write to this test's own
/// directory, and only when something reaches the top.
/// </remarks>
public sealed class CrashLogTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-crash-{Guid.CreateVersion7():N}");
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void AnExceptionThatReachesTheTop_IsWrittenToTheProfile()
{
var paths = new ClientPaths(directory);
CrashLog.Install(paths);
CrashLog.Write("UI thread", new InvalidOperationException("the thing nobody caught"));
var written = File.ReadAllText(paths.CrashLogFile);
written.ShouldContain("UI thread");
written.ShouldContain("the thing nobody caught");
written.ShouldContain(nameof(InvalidOperationException));
}
}
@@ -0,0 +1,56 @@
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
using NSubstitute;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// That the slow probe choosing a device key store runs once, and not on the thread that asked.
/// </summary>
/// <remarks>
/// On Windows the probe creates and deletes a TPM key, and it used to run synchronously on the UI thread at
/// startup and again after every unlock. See <see cref="ProbedDeviceKeyStore"/>.
/// </remarks>
public sealed class ProbedDeviceKeyStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task TheChoiceIsMadeOnce_OffTheCallersThread()
{
var asking = Environment.CurrentManagedThreadId;
var probes = 0;
var probedOn = 0;
var inner = Substitute.For<IDeviceKeyStore>();
var store = new ProbedDeviceKeyStore(() =>
{
Interlocked.Increment(ref probes);
probedOn = Environment.CurrentManagedThreadId;
return inner;
});
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
await store.TryLoadAsync(Token);
probes.ShouldBe(1);
probedOn.ShouldNotBe(asking);
// Availability is the choice, not a second probe through the store it chose.
await inner.DidNotReceive().IsAvailableAsync(Arg.Any<CancellationToken>());
await inner.Received(1).TryLoadAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task AMachineWithNoStore_IsUnavailable_AndForgettingIsHarmless()
{
var store = new ProbedDeviceKeyStore(static () => new UnavailableDeviceKeyStore());
(await store.IsAvailableAsync(Token)).ShouldBeFalse();
(await store.TryLoadAsync(Token)).ShouldBeNull();
await store.ForgetAsync(Token);
}
}
@@ -639,6 +639,36 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync");
}
/// <remarks>
/// Lock pressed while an unlock is still loading. The unlock is held at the point the vault has been handed
/// over and is about to be read — the device-key probe — and the lock lands there. The unlock used to carry
/// on regardless, into a vault the lock had disposed and a shell that no longer held one, and put an
/// exception's message on the lock screen. An earlier lock, before the hand-over, would have left the vault
/// opening behind the lock screen; both are the same check.
/// </remarks>
[Fact]
public async Task LockingWhileAnUnlockIsStillLoading_LeavesItLocked()
{
await EnrolledAndConfirmedAsync();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
deviceKeys.HoldAvailability = release.Task;
shell.Passphrase = Passphrase;
var unlocking = shell.UnlockCommand.ExecuteAsync(null);
await EventuallyAsync(() => shell.Vault is not null, "the unlock should reach the hand-over and wait there");
await shell.LockCommand.ExecuteAsync(null);
release.SetResult();
await unlocking;
shell.State.ShouldBe(ShellState.Locked);
shell.Vault.ShouldBeNull();
shell.StatusMessage.ShouldBe("Locked.");
}
[Fact]
public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo()
{
@@ -656,6 +686,26 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
vault.IsBusy.ShouldBeFalse("a background pass must never raise the busy flag");
}
/// <remarks>
/// A pass that moved nothing used to read the whole keychain back anyway — decrypting every item and
/// rebuilding every list, once a minute. A rebuilt list has new row objects in it, so the row still being
/// the same object is the evidence that nothing was read.
/// </remarks>
[Fact]
public async Task AnAutomaticPassThatMovesNothing_RedrawsNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
var row = vault.Hosts.ShouldHaveSingleItem();
await vault.AutoSyncAsync(Token);
vault.Hosts.ShouldHaveSingleItem().ShouldBeSameAs(row);
}
/// <remarks>
/// The queue has to be arranged by failing the automatic push first. Written the obvious way — add a
/// host, then call the pass — this test proved nothing at all: saving pushes, so there was no pending
@@ -79,6 +79,22 @@ public sealed class TransferQueueingTests : IDisposable
transfers.Status.ShouldContain("Connect to a host first");
}
/// <remarks>
/// A drop landing while another command on this screen was running used to vanish: nothing queued and
/// nothing said. It still queues nothing — the command in flight owns the pane — but it says so.
/// </remarks>
[Fact]
public void DroppingFilesWhileBusy_QueuesNothingAndSaysWhy()
{
Connected();
transfers.IsBusy = true;
transfers.QueueUploads([File("one.txt")]);
Queued().ShouldBeEmpty();
transfers.Status.ShouldContain("Nothing was queued");
}
[Fact]
public void DroppingSeveralFiles_QueuesEachOfThem()
{
@@ -53,7 +53,7 @@ public sealed class WindowsDeviceKeyStoreTests
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
store.ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.ShouldBeOfType<ProbedDeviceKeyStore>().Chosen).ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
}
@@ -77,9 +77,7 @@ public sealed class WindowsDeviceKeyStoreTests
private static void SkipUnlessSupported()
{
var supported = OperatingSystem.IsWindows()
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin)
is WindowsDeviceKeyStore;
var supported = OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported();
if (!supported)
{
@@ -25,8 +25,18 @@ internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
internal void Overwrite(byte[] replacement) => key = replacement;
/// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
ValueTask.FromResult(IsAvailable);
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken)
{
if (HoldAvailability is { } hold)
{
await hold.WaitAsync(cancellationToken).ConfigureAwait(false);
}
return IsAvailable;
}
/// <summary>When set, availability is not answered until it completes: an unlock held part-way.</summary>
internal Task? HoldAvailability { get; set; }
/// <inheritdoc />
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
@@ -28,6 +28,44 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <remarks>
/// Opening a shell is three round trips to the server, and SSH.NET makes them synchronously. They used to
/// be made on the caller's thread — the UI thread — behind a <c>Task.FromResult</c>, so the task this
/// returned was already finished and the window had stopped painting while it was being made.
/// </remarks>
[Fact]
public async Task OpeningAShell_LeavesTheCallingThreadFree()
{
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
var opening = connection.OpenShellAsync(TerminalSize.Default, Token);
opening.IsCompleted.ShouldBeFalse("three round trips to the server cannot have been made on this thread");
await using var shell = await opening;
}
/// <remarks>
/// The cancellation token used to be read once, before any of those round trips. A caller that gives up
/// now stops waiting at once, and the connection is left able to open the next shell.
/// </remarks>
[Fact]
public async Task AShellGivenUpOn_StopsTheWait_AndTheConnectionOpensAnother()
{
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(Token);
var opening = connection.OpenShellAsync(TerminalSize.Default, giveUp.Token);
await giveUp.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(opening);
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
}
[Fact]
public async Task AKeyHeldAsBytes_AuthenticatesAndOpensAShell()
{
@@ -128,6 +128,42 @@ public sealed class CacheStoreTests : IAsyncLifetime
items.ShouldHaveSingleItem().Version.ShouldBe(2);
}
/// <remarks>
/// A pull's page in one write. What it leaves has to be exactly what the same items saved one at a time
/// would have left: rows that existed replaced, new ones created, and an item given twice ending at the
/// later of the two — a page can carry several changes to the same item.
/// </remarks>
[Fact]
public async Task SavingMany_LeavesWhatSavingThemOneAtATimeWould()
{
var existing = Guid.CreateVersion7();
var fresh = Guid.CreateVersion7();
var twice = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(existing, version: 1, seed: 1), Token);
await harness.Items.SaveManyAsync(
[
Item(existing, version: 2, changeSequence: 2, seed: 2),
Item(fresh, version: 1, changeSequence: 3, seed: 3),
Item(twice, version: 1, changeSequence: 4, seed: 4),
Item(twice, version: 2, changeSequence: 5, deleted: true),
],
Token);
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, existing, Token))
.ShouldNotBeNull().Version.ShouldBe(2);
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, fresh, Token))
.ShouldNotBeNull().Version.ShouldBe(1);
var last = (await harness.Items.FindAsync(VaultId, SyncEntityType.Host, twice, Token)).ShouldNotBeNull();
last.Version.ShouldBe(2);
last.IsDeleted.ShouldBeTrue();
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).Count.ShouldBe(3);
}
[Fact]
public async Task ATombstone_IsHiddenFromTheListButStillFindable()
{
@@ -147,7 +147,8 @@ public sealed class UnpushedDeleteTests
// The ambiguous case, and the reason the attempt count is part of the test rather than just the
// expected version. A create that was dispatched may have been applied by a server whose answer never
// arrived. Dropping it locally would strand a row nobody can see and nobody can delete; a tombstone
// that turns out to be unnecessary is refused once and can be dealt with.
// that turns out to be unnecessary is refused once, and the refusal is taken as the delete being done —
// see the two tests after this one.
using var harness = await CreateAsync();
var laptop = harness.First;
@@ -166,6 +167,64 @@ public sealed class UnpushedDeleteTests
tombstone.Operation.ShouldBe(SyncOperation.Delete);
}
/// <remarks>
/// The rest of that story, when the create never did land. The tombstone carries no expected version, and
/// the server refuses it because it has no such item — which is what the delete wanted. It used to be
/// parked as a rejection the user could neither resolve nor dismiss, with a pending count stuck above zero.
/// </remarks>
[Fact]
public async Task ATombstoneForACreateThatNeverLanded_IsDoneWhenTheServerSaysItHasNoSuchItem()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("in-flight"));
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
await laptop.DeleteAsync(entityId);
var report = await laptop.SyncAsync();
report.Parked.ShouldBe(0, "the server not having the item is the delete succeeding");
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
harness.Server.RowCount.ShouldBe(0);
}
/// <remarks>
/// And when it did land, the shortcut above must not be what answers. The pull brings the item back first,
/// at a version the tombstone never knew about, and the engine's ordinary rule for that applies: the delete
/// gives way, the item stays, and the user is told — nothing parked, nothing lost, nothing stuck in the
/// outbox. Deleting it again is then an ordinary delete of an item the server holds.
/// </remarks>
[Fact]
public async Task ATombstoneForACreateThatDidLand_GivesWayToIt_RatherThanBeingTakenAsDone()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("in-flight"));
// The create reaches the server with its answer lost: the server holds the item while the laptop's own
// row still says "sent, unanswered".
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
harness.Server.ExternalUpsert(entityId, queued.Payload.ShouldNotBeNull(), queued.Fields);
await laptop.DeleteAsync(entityId);
var report = await laptop.SyncAsync();
report.DeletesAbandoned.ShouldBe(1, "the user is told the item came back");
report.Parked.ShouldBe(0);
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
harness.Server.RowCount.ShouldBe(1);
await laptop.DeleteAsync(entityId);
await laptop.SyncAsync();
harness.Server.RowCount.ShouldBe(0, "deleting it again reaches the server");
}
/// <remarks>
/// The path most likely to meet this in practice, which is why it gets its own test rather than trusting
/// the generic repository: trust is pinned by connecting and withdrawn from the host editor, so trusting a
@@ -304,6 +304,36 @@ public sealed class FileTransferQueueTests : IDisposable
finished.Failure.ShouldBe("the host is not reachable");
}
/// <remarks>
/// <para>
/// The pump decides the queue is empty under the lock and only finishes its task after letting go, so a
/// transfer queued in that gap used to find a pump that still looked busy, start none, and sit queued for
/// ever. Queuing each transfer the moment the last one reports finishing is what lands in that gap.
/// </para>
/// <para>
/// A race, so it is run many times rather than arranged once; on the code that had it, a few hundred
/// rounds strand a transfer every time. The session factory refuses at once, which keeps each round to
/// the queue's own bookkeeping — the part the race is in.
/// </para>
/// </remarks>
[Fact]
public async Task ATransferQueuedAsTheLastOneFinishes_StillRuns()
{
await using var queue = new FileTransferQueue(
_ => Task.FromException<IRemoteFileStore>(new IOException("refused at once")),
TimeProvider.System);
for (var round = 0; round < 500; round++)
{
var finished = await RunToCompletionAsync(
queue, TransferDirection.Download, Local($"round-{round}"), Remote($"round-{round}"), 1);
finished.State.ShouldBe(TransferState.Failed, $"round {round}");
}
queue.IsBusy.ShouldBeFalse();
}
private FileTransferQueue NewQueue() =>
new(_ => Task.FromResult<IRemoteFileStore>(host), TimeProvider.System);
@@ -371,7 +401,7 @@ public sealed class FileTransferQueueTests : IDisposable
{
start();
return await finished.Task.WaitAsync(TimeSpan.FromSeconds(30), Token);
return await finished.Task.WaitAsync(TimeSpan.FromSeconds(10), Token);
}
finally
{