Files
DodoSSH/src/DodoSSH.Client.Session/ConnectionRecorder.cs
T
jaap-jan d07b336868 Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain
hid every open terminal and the strip that named them. A connection you had
opened was invisible from four of the five screens. The window now has two
surfaces rather than one: a nav rail that says which page you are on, and a
terminal strip that is always there and switches the whole content area to a
shell. Screen keeps meaning "which page" and never becomes a sixth kind of
page, which is why this is two properties instead of one enum with a terminal
member in it.

Every screen lives inside one wrapper panel that collapses when a terminal is
showing. That is not tidiness — the WebView hosts a Win32 child window that
composites above everything Avalonia draws, so a screen left visible over its
rectangle is a screen sliced in half, and this window has shipped that defect
once already. One decision point, IsTerminalShowing, and a nested panel rather
than five compound bindings nobody would remember to extend.

The focus choreography is the part no test in this repo can see. Every reveal
path now focuses in the same turn the WebView appeared, so all three of them
post at DispatcherPriority.Loaded and let the native control re-push its bounds
first. Going the other way had a real bug: the screen-changed branch called a
bare Focus() where it had to release the keyboard from the native child, so
switching from a terminal to Files silently ate the first keystrokes. Rare
before this commit and the primary gesture after it.

The tab strip grew a cross inside each tab, a plus that opens the quick-connect
palette, and middle-click close. Nested buttons are correct here: Avalonia
handles a left press on the cross and deliberately does not handle other
buttons, which is exactly what lets middle-click bubble up from the cross as
well as the tab. The test is PointerUpdateKind rather than
IsMiddleButtonPressed, because the latter reports button state and is also true
for a left press made while the middle button happens to be held. The handler
is on the tab and not the strip, so the background closes nothing by
construction. Plus opens the palette rather than a flyout, since a menu
dropping into the WebView's rectangle may or may not composite above a child
HWND and this repo does not make rendering claims it has not photographed.

Everything a user reads now says keychain. The wire, the database and the
cryptographic spec still say vault, deliberately: renaming those is a migration
and a protocol change for a word. That split is written down rather than left
to be rediscovered as an inconsistency.

Four things that were squeezed into the keychain's category rail, or into
nothing at all, now have screens. Pinned host keys get one, with fingerprints
never truncated and a filter that matches them, because comparing what you have
against what the operator published is the whole workflow; the approved date is
read out of the item's UUIDv7 rather than added as a column, and says so, since
it means first approval and not last use. Keys can be generated in the client,
which needed the openssh-key-v1 container written by hand — there is no BCL or
NSec helper, and the PKCS#8 route is unverified in the SSH library this uses.
The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is
Blowfish with a swizzle, in a project whose crypto is otherwise entirely
libsodium, for a protection the key's own remarks argue is redundant inside a
vault. Generation fills the existing editor and stops, so SAVE stays the one
thing that writes. ~/.ssh/config can be imported behind a preview that is
ticked per row and writes nothing until the button; IdentityFile records the
path and imports the key material only on an explicit opt-in, because reading
somebody's private key into a vault is precisely the act this product exists to
make deliberate. Match blocks and ProxyJump are reported rather than obeyed —
one cannot be evaluated statically and the other has nothing behind it to route
with, and a preview that implied otherwise would be worse than one that admits
it.

Files can be dragged in all four directions that are honestly available. Remote
to Explorer does not ship and is not pretended to: the shell wants the bytes
during the drop, which needs a virtual file and a native COM data object,
outside what Avalonia offers. Note for the next person that Avalonia 12
replaced the drag model outright — DataObject and DataFormats are no-op stubs
and IDataObject is not in the reference assembly, so every tutorial written for
11 does not compile here.

Hosts can be grouped, flat and never nested. A parent id merged as a scalar
lets two offline clients each re-parent A under B and B under A, producing a
cycle inside an encrypted payload that no server can police and every reader
would have to detect for ever. Membership lives in that payload rather than in
the one plaintext concession ADR 0001 allows, whose test is that the relay
cannot function without it — nothing on the server reads a group, so what
plaintext would hand over is a clustering of the estate for nothing. The
plaintext column reserved for it is dropped, provably always null, and the
server now refuses a client that sends one; it was never populated, was copied
on apply, and was not cleared on delete, so a group id would have outlived the
host it described.

Snippets insert through xterm rather than through the pump, because xterm is
the only thing that knows whether the remote has bracketed paste on, and that
is what makes a shell treat embedded newlines as text instead of as execute.
The host process moves opaque bytes and never parses output, so it would have
to guess, and guessing wrong runs every line. Running is off by default and the
copy says the text goes into whatever is there — the terminal has no notion of
being at a prompt, and may be in vi or at a password prompt with echo off, so
the Enter the user presses themselves is the entire safety property.

Connections and keychain changes are recorded as synced encrypted items, which
is what makes them auditable by a team later and costs the server knowledge of
connection rate and timing from row counts alone. ADR 0001 already concedes it
cannot hide that class of metadata; the trade is now written into it rather
than left implicit. A connection entry is written once, at close, which is what
makes a synced log tractable: nothing to merge, one outbox row, no chance of
colliding with itself. Live sessions come from memory, not from the log. The
write is void by contract and posts to a bounded channel, because putting an
encrypt-and-write on the teardown path of every session is how closing the
application comes to take four seconds. A ticket opened before a lock still
closes afterwards, since a shell outlives the vault. The activity log hooks the
one generic repository every kind writes through, so it cannot miss a caller —
which is also why the log kinds themselves declare they are not audited, or the
first entry would write an entry about writing an entry. It records the names
of the fields that changed and never their values; a log with an old password
in it would be a plaintext credential store with no vault around it. Retention
is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop
rather than on a second timer.

That log traffic then broke the status line, which is worth recording because
the fix is a shape and not a patch: background sync counted its own log rows as
pushed items, so the quiet rule stopped being quiet and every action's message
was overwritten a second later by a sync report. The report now separates log
rows from user items and the rule reads the latter.

S3 buckets appear as a remote in the file browser, behind the same interface an
SFTP session implements, so the queue and both panes did not have to learn what
they are talking to. Uploads go through a pipe, because the queue wants to
write and the SDK wants to read; memory is then bounded by the part size
instead of buffering a file to disk twice.

Finally, the Windows device key store moved out of the session project, which
was the one thing keeping it from being portable — everything else in it is
platform-neutral, and a Windows CNG dependency in the middle of the vault code
meant a second head could not reference it without dragging Windows along. The
seam that made the move free was already there. docs/android-port.md is the
audit behind that: what ports, what does not, in order of cost, the four
decisions taken, and an inventory of every screen and state the interface has
to carry, written so a design can be made from it directly.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1240 tests at zero warnings, including the end-to-end suite against real
containers. The manual checks that headless Avalonia cannot make — the drag
from Explorer, a generated key against a real host, twelve tabs at the minimum
window width — are listed in docs/manual-checks.md and are still outstanding.
2026-07-31 20:30:05 +02:00

411 lines
16 KiB
C#

using System.Threading.Channels;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Sync;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.Session;
/// <summary>A connection that has started and has no log entry yet, because it has not ended.</summary>
/// <param name="HostLabel">What the host is called.</param>
/// <param name="Address">The address as dialled.</param>
/// <param name="StartedAt">When it opened.</param>
public sealed record OpenConnection(string HostLabel, string Address, DateTimeOffset StartedAt);
/// <summary>
/// Records connections into whichever vault is open, without ever making the caller wait.
/// </summary>
/// <remarks>
/// <para>
/// <b>A process-lifetime object with session-scoped contents</b>, exactly like <see cref="VaultKnownHostStore"/>
/// and for the same reason: the workspace that calls it is composed once at startup and outlives every lock,
/// so a recorder created per session would have to be threaded through an object that must not know about
/// vaults at all. <see cref="Open"/> on unlock, <see cref="Close"/> on lock.
/// </para>
/// <para>
/// <b>Nothing on the calling thread does any work.</b> Both interface methods take a lock, touch a
/// dictionary, and post to a bounded channel; one background task drains it and does the encrypting and
/// writing. That is not tidiness — <c>Closed</c> is called from a <c>finally</c> unwinding on a thread-pool
/// thread while the application is shutting down, once per open tab, and an encrypt-and-write there is
/// exactly how closing an application comes to take four seconds.
/// </para>
/// <para>
/// <b>A shell can outlive the vault, so close-out has to as well.</b> A tab opened before a lock and closed
/// after it still deserves its entry — the connection genuinely happened — so the ticket keeps the repository
/// it was opened against rather than reading whichever one is current. The write then fails if the session
/// behind it has been disposed, which is swallowed like every other failure here: an advisory log line is
/// never worth surfacing an error over.
/// </para>
/// <para>
/// <b>The queue is bounded and drops the oldest when full.</b> An unbounded one would turn a stuck write into
/// unbounded memory, and blocking would turn it into a hung shutdown. Losing the oldest few entries of a
/// backlog that is already thousands deep is the least bad of the three, and it is the direction that keeps
/// the newest — which is what somebody reading a log actually wants.
/// </para>
/// </remarks>
public sealed class ConnectionRecorder : IConnectionLogSink, IAsyncDisposable
{
/// <summary>
/// How many close-outs may be waiting to be written.
/// </summary>
/// <remarks>
/// Far more than the tabs anybody has open, so the cap is only ever reached by a write path that has
/// stopped draining — which is the case it exists for.
/// </remarks>
private const int QueueDepth = 256;
/// <summary>How long <see cref="DisposeAsync"/> waits for the queue to be written.</summary>
/// <remarks>
/// Long enough for the handful of entries a normal exit produces — each is one encrypt and one local
/// write — and short enough that a stuck cache cannot become a window that will not close.
/// </remarks>
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
private readonly Channel<PendingEntry> pending = Channel.CreateBounded<PendingEntry>(
new BoundedChannelOptions(QueueDepth)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
});
private readonly Dictionary<uint, OpenTicket> tickets = [];
private readonly Lock gate = new();
private readonly TimeProvider clock;
private readonly string deviceName;
private readonly Task drain;
private readonly CancellationTokenSource lifetime = new();
private Binding? binding;
private int disposed;
/// <param name="clock">Time source. Used only for a duration this type did not receive.</param>
/// <param name="deviceName">What this machine calls itself, recorded on every entry.</param>
public ConnectionRecorder(TimeProvider clock, string deviceName)
{
ArgumentException.ThrowIfNullOrWhiteSpace(deviceName);
this.clock = clock;
this.deviceName = deviceName;
drain = DrainAsync(lifetime.Token);
}
/// <summary>
/// The connections that have opened and not yet been recorded.
/// </summary>
/// <remarks>
/// For the logs screen, which shows these above the finished entries. It reads them from here rather
/// than from the tab strip because these are exactly the tickets the log is waiting to close — so a row
/// on that screen appears and disappears in step with the entry that will replace it, rather than in
/// step with a tab, which is a different thing that merely usually agrees.
/// </remarks>
public IReadOnlyList<OpenConnection> Open()
{
lock (gate)
{
return
[
.. tickets.Values
.Select(ticket => new OpenConnection(
ticket.HostLabel, ticket.Address, ticket.StartedAt))
.OrderByDescending(open => open.StartedAt),
];
}
}
/// <summary>Whether a vault is open behind this recorder.</summary>
public bool IsOpen
{
get
{
lock (gate)
{
return binding is not null;
}
}
}
/// <summary>Starts recording into an unlocked vault.</summary>
/// <param name="session">The unlocked session. Its active vault is the one written to.</param>
/// <param name="actorUserId">Which account this is, recorded on every entry.</param>
public void Open(VaultSession session, Guid actorUserId)
{
ArgumentNullException.ThrowIfNull(session);
lock (gate)
{
binding = new Binding(session.ConnectionLog, session.ActiveVaultId, actorUserId);
}
}
/// <summary>
/// Stops recording new connections.
/// </summary>
/// <remarks>
/// Open tickets are deliberately <em>not</em> discarded. Each already holds the repository it was opened
/// against, so a shell still running when the vault locks closes out into the vault it was made from —
/// which is the honest record. What is dropped is the ability to <em>start</em> a ticket, because a
/// connection made while locked has no vault to belong to.
/// </remarks>
public void Close()
{
lock (gate)
{
binding = null;
}
}
/// <inheritdoc />
public void Opened(uint sessionId, string address, DateTimeOffset startedAt)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
lock (gate)
{
if (binding is not { } open)
{
return;
}
// The address stands in for the name until Identify supplies one, so a connection made by
// something that never calls it is still recorded — with a worse label, which beats no entry.
tickets[sessionId] = new OpenTicket(
open, address, address, HostId: null, ConnectionKind.Terminal, startedAt);
}
}
/// <summary>
/// Names the host an already-open session belongs to.
/// </summary>
/// <param name="sessionId">The session, as the workspace knows it.</param>
/// <param name="hostLabel">What the host is called in the keychain.</param>
/// <param name="hostId">The host item.</param>
/// <remarks>
/// <para>
/// The workspace takes an <c>SshConnectionRequest</c>, which has no notion of a keychain item, so it
/// knows an address and nothing else. The label and the id arrive here instead, from the view model that
/// does know — and as an amendment rather than a second ticket, so the start time stays the one the
/// workspace recorded rather than the slightly later one this call would carry.
/// </para>
/// <para>
/// A session id with no ticket is ignored, which is what a connection made while the vault was locked
/// looks like.
/// </para>
/// </remarks>
public void Identify(uint sessionId, string hostLabel, Guid? hostId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
lock (gate)
{
if (tickets.TryGetValue(sessionId, out var ticket))
{
tickets[sessionId] = ticket with { HostLabel = hostLabel, HostId = hostId };
}
}
}
/// <inheritdoc />
public void Closed(uint sessionId, DateTimeOffset endedAt)
{
OpenTicket ticket;
lock (gate)
{
if (!tickets.Remove(sessionId, out var found))
{
// Never opened, already closed, or opened while the vault was locked. All three mean there
// is nothing to record, and none of them is an error.
return;
}
ticket = found;
}
Queue(ticket, endedAt, ConnectionOutcome.Closed);
}
/// <summary>
/// Records a connection that was never a workspace session.
/// </summary>
/// <param name="address">The address that was dialled.</param>
/// <param name="hostLabel">What the host is called.</param>
/// <param name="hostId">The host item, if there was one.</param>
/// <param name="kind">Which sort of session it was.</param>
/// <param name="startedAt">When it began.</param>
/// <param name="endedAt">When it ended, which is the same instant for an attempt that failed.</param>
/// <param name="outcome">How it ended.</param>
/// <remarks>
/// <para>
/// Two callers, both outside the terminal workspace's id space, which is why this takes no session id:
/// a connection that never opened — the workspace throws out of <c>ConnectAsync</c> before an id exists,
/// so there is nothing to open a ticket for — and an SFTP session, which is a separate connection
/// entirely and would collide with a terminal's id if it borrowed one.
/// </para>
/// <para>
/// A run of refusals against one host is the single most interesting thing a connection log can show,
/// which is why the failures are recorded at all rather than only the sessions that worked.
/// </para>
/// </remarks>
public void Record(
string address,
string hostLabel,
Guid? hostId,
ConnectionKind kind,
DateTimeOffset startedAt,
DateTimeOffset endedAt,
ConnectionOutcome outcome)
{
ArgumentException.ThrowIfNullOrWhiteSpace(address);
ArgumentException.ThrowIfNullOrWhiteSpace(hostLabel);
Binding open;
lock (gate)
{
if (binding is not { } current)
{
return;
}
open = current;
}
Queue(new OpenTicket(open, address, hostLabel, hostId, kind, startedAt), endedAt, outcome);
}
/// <summary>
/// Closes out every still-open connection and writes what is queued, within a bounded wait.
/// </summary>
/// <remarks>
/// <para>
/// <b>Closing the application is the ordinary way a session ends</b>, and without this every one of them
/// would be lost: the workspace's own close-outs happen while it tears its sessions down, which is after
/// the vault they would be written into has gone. So the tickets are closed here instead, while there is
/// still something to write to, and the durations run to the moment of exit — which is what actually
/// happened.
/// </para>
/// <para>
/// <b>The wait is bounded and the remainder is dropped.</b> An advisory log is never worth making a
/// process refuse to exit, so a queue that will not drain costs its entries rather than the user's
/// patience.
/// </para>
/// </remarks>
public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref disposed, 1) == 1)
{
return;
}
OpenTicket[] remaining;
lock (gate)
{
remaining = [.. tickets.Values];
tickets.Clear();
binding = null;
}
var at = clock.GetUtcNow();
foreach (var ticket in remaining)
{
Queue(ticket, at, ConnectionOutcome.Closed);
}
pending.Writer.TryComplete();
try
{
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// Whatever is left goes unwritten. Stated rather than logged: there is nowhere left to log it.
}
await lifetime.CancelAsync().ConfigureAwait(false);
try
{
await drain.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected: cancelling is how the loop is asked to stop.
}
lifetime.Dispose();
}
private void Queue(OpenTicket ticket, DateTimeOffset endedAt, ConnectionOutcome outcome)
{
// A duration rather than an end time, and clamped at zero: the two stamps come from the same clock,
// but a machine that resumed from sleep between them can still produce a negative one, and the
// payload refuses those outright.
var duration = endedAt > ticket.StartedAt ? endedAt - ticket.StartedAt : TimeSpan.Zero;
var entry = new ConnectionLogSecret
{
HostLabel = ticket.HostLabel,
Address = ticket.Address,
HostId = ticket.HostId,
Kind = ticket.Kind,
StartedAt = ticket.StartedAt,
Duration = duration,
Outcome = outcome,
DeviceName = deviceName,
ActorUserId = ticket.Binding.ActorUserId,
};
// TryWrite, never WriteAsync. The whole contract of this type is that the caller does not wait, and
// a bounded channel with DropOldest never refuses anyway.
pending.Writer.TryWrite(new PendingEntry(ticket.Binding, entry));
}
private async Task DrainAsync(CancellationToken cancellationToken)
{
try
{
await foreach (var item in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
{
try
{
await item.Binding.Log
.CreateAsync(item.Binding.VaultId, item.Entry, cancellationToken)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
// Swallowed, and this is the rule rather than an omission: a log entry is advisory, and
// there is no caller left to tell. The realistic failures are a session disposed between
// the queue and the write — a shell closed after the vault locked — and a cache that has
// gone away underneath it. Neither is worth an unobserved exception on a background task.
}
}
}
catch (OperationCanceledException)
{
// Shutting down.
}
}
/// <summary>Which vault entries go to, and who is making them.</summary>
private sealed record Binding(ConnectionLogRepository Log, Guid VaultId, Guid ActorUserId);
/// <summary>A connection that has started and not yet been recorded.</summary>
/// <remarks>
/// It carries its own <see cref="Binding"/> rather than reading the current one at close time, which is
/// what lets a session outlive the vault it was opened in without being filed into the next one.
/// </remarks>
private sealed record OpenTicket(
Binding Binding,
string Address,
string HostLabel,
Guid? HostId,
ConnectionKind Kind,
DateTimeOffset StartedAt);
private sealed record PendingEntry(Binding Binding, ConnectionLogSecret Entry);
}