using System.Diagnostics.CodeAnalysis; using System.Globalization; namespace DodoSSH.Client.Domain; /// How a connection ended. public enum ConnectionOutcome { /// The session ran and then ended — by the user, by the remote, or by the process closing. /// /// One value for all three, deliberately. From an auditor's side "this person had a shell on that machine /// for eleven minutes" is the fact; which of the two ends hung up first is not something this client can /// establish reliably — a tab close and a remote hangup both arrive as the pump finishing — and a field /// that guessed would be worse than one that does not claim to know. /// Closed = 0, /// The connection was attempted and did not open. Failed = 1, /// The host key was not the pinned one, so the client refused before authenticating. /// /// Its own outcome rather than a kind of , because it is the only one that means /// something about the host rather than about the network or the credentials. A run of these on /// one machine is the single most interesting thing a connection log can show. /// Refused = 2, } /// What kind of session a log entry is about. public enum ConnectionKind { /// An interactive terminal. Terminal = 0, /// An SFTP session for moving files. /// /// Recorded separately and not hidden. Opening the file browser is a second login as far as the remote's /// own auth.log is concerned, so a log of ours that quietly omitted it would disagree with the /// host's — and the person comparing the two would be right to trust the host. /// Sftp = 1, } /// /// One connection that was made, decrypted. /// /// /// /// What is here, and what deliberately is not. The host's label, its item id, the address as dialled, /// when it started, how long it lasted, how it ended, and which user on which device did it. An audit log /// with no actor is not an audit log — the whole reason these sync is that an administrator will read them /// once teams land — so the actor is recorded and the SSH username is not. The two are different questions: /// "who in this organisation opened a shell" is what an audit answers, and "which account they logged in as" /// is a detail of the host that the host's own logs already have. /// /// /// Written once, at close. Every field is known by then, so an entry never needs a second write — /// which is what keeps a synced log from needing a merge, an outbox row per update, or any way to collide /// with itself. A connection that is still running is not in here at all; it is shown from the workspace's /// live state, which is the only place that knows. /// /// public sealed record ConnectionLogSecret : IVaultSecret { /// What the host was called at the time, or a plain address when nothing named it. /// /// A copy rather than a lookup through , and that is the point of it: the bookmark /// can be renamed or deleted, and a history that changed retroactively when somebody tidied their /// keychain would be a history nobody could rely on. /// public required string HostLabel { get; init; } /// The address as dialled, user@host:port style, or whatever was typed. public required string Address { get; init; } /// The host item this was, or null when the connection did not come from one. public Guid? HostId { get; init; } /// Whether this was a terminal or a file-transfer session. public ConnectionKind Kind { get; init; } /// When it started. public required DateTimeOffset StartedAt { get; init; } /// How long it lasted. /// /// A duration rather than an end time, because it is the thing anybody reads — and because the two clocks /// involved are the same one, so storing both would be storing a value and its own arithmetic. /// public TimeSpan Duration { get; init; } /// How it ended. public ConnectionOutcome Outcome { get; init; } /// Which machine it was made from, as that machine calls itself. public required string DeviceName { get; init; } /// Which account in this organisation made it. public Guid ActorUserId { get; init; } /// What this entry is called, derived from what it records. /// public string Label => string.Create(CultureInfo.InvariantCulture, $"{HostLabel} ({Address})"); /// Whether this is storable, and why not if it is not. /// /// A negative duration is refused rather than clamped. It can only come from a payload written elsewhere /// — nothing here can produce one — and a log that displayed "-3 hours" would leave a reader unable to /// tell a corrupt entry from a clock they should worry about. /// public bool TryValidate([NotNullWhen(false)] out string? reason) { if (string.IsNullOrWhiteSpace(HostLabel)) { reason = "A connection log entry needs the host it was about."; return false; } if (string.IsNullOrWhiteSpace(Address)) { reason = "A connection log entry needs the address that was dialled."; return false; } if (string.IsNullOrWhiteSpace(DeviceName)) { reason = "A connection log entry needs the machine it was made from."; return false; } if (Duration < TimeSpan.Zero) { reason = "A connection cannot have lasted a negative amount of time."; return false; } if (HostId == Guid.Empty) { reason = "A host reference cannot be an empty id; use no host instead."; return false; } reason = null; return true; } }