Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -46,13 +46,23 @@ public interface IVaultAccessService
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// M1 supports personal vaults only, so the rule is ownership. Team vaults, the
|
||||
/// <c>v_user_vault_permission</c> view and per-resource ACLs arrive in M3 — this is the one place
|
||||
/// that changes, which is why every caller goes through it rather than comparing owner ids inline.
|
||||
/// Two rules, and only two. A personal vault answers to its owner. A team vault answers to the
|
||||
/// team's active members, with the role deciding how much. Everything else is denied, which is what
|
||||
/// keeps an unimplemented ownership kind from falling through to a permissive default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A team vault is explicitly denied for now rather than falling through to a permissive default.
|
||||
/// Failing closed on an unimplemented path is the only safe direction.
|
||||
/// <b>Permission is not the same thing as readability.</b> This service decides what the
|
||||
/// <em>server</em> will serve; whether the caller can decrypt what it serves depends on holding a
|
||||
/// vault key grant, which the server cannot produce and cannot verify. A member with Read and no
|
||||
/// grant is a normal, temporary state — they have just been added, or the vault has been rekeyed —
|
||||
/// and <c>VaultSummary.WrappedVaultKey</c> is null for exactly that case. Conflating the two here
|
||||
/// would mean a newly added member's vault silently vanished from their list instead of appearing
|
||||
/// and saying it is waiting for a key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately not a database view. <c>v_user_vault_permission</c> was sketched for this, and the
|
||||
/// rules turned out to be sixteen lines of C# that both methods share — a view would have put the
|
||||
/// authorization model somewhere migrations own and tests cannot reach without a container.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessService
|
||||
@@ -80,14 +90,23 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
|
||||
return VaultAccess.Denied;
|
||||
}
|
||||
|
||||
if (vault.OwnerKind == VaultOwnerKind.Personal && vault.OwnerUserId == userId)
|
||||
if (vault.OwnerKind == VaultOwnerKind.Personal)
|
||||
{
|
||||
return new VaultAccess(vault, OwnerPermissions);
|
||||
return vault.OwnerUserId == userId
|
||||
? new VaultAccess(vault, OwnerPermissions)
|
||||
: VaultAccess.Denied;
|
||||
}
|
||||
|
||||
// Team vaults are not readable until M3 wires up membership and grants. Denying is the
|
||||
// correct behaviour in the meantime.
|
||||
return VaultAccess.Denied;
|
||||
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
|
||||
{
|
||||
return VaultAccess.Denied;
|
||||
}
|
||||
|
||||
var role = await FindRoleAsync(userId, teamId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return role is { } granted
|
||||
? new VaultAccess(vault, ForRole(granted))
|
||||
: VaultAccess.Denied;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -95,16 +114,98 @@ internal sealed class VaultAccessService(DodoDbContext database) : IVaultAccessS
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Personal ownership only, matching ResolveAsync. When M3 adds the
|
||||
// v_user_vault_permission view, both methods change together and neither can drift.
|
||||
// The memberships first, then one pass over the vaults. The alternative — a join per vault —
|
||||
// would be the same answer at more round trips, and a user belongs to a handful of teams.
|
||||
var roles = await database.TeamMemberships
|
||||
.Where(m => m.UserId == userId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.ToDictionaryAsync(m => m.TeamId, m => m.Role, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var teamIds = roles.Keys.ToArray();
|
||||
|
||||
var vaults = await database.Vaults
|
||||
.Where(v => v.OwnerKind == VaultOwnerKind.Personal
|
||||
&& v.OwnerUserId == userId
|
||||
&& v.DeletedAtUtc == null)
|
||||
.Where(v => v.DeletedAtUtc == null
|
||||
&& ((v.OwnerKind == VaultOwnerKind.Personal && v.OwnerUserId == userId)
|
||||
|| (v.OwnerKind == VaultOwnerKind.Team
|
||||
&& v.TeamId != null
|
||||
&& teamIds.Contains(v.TeamId.Value))))
|
||||
.OrderBy(v => v.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return [.. vaults.Select(v => new VaultAccess(v, OwnerPermissions))];
|
||||
var accessible = new List<VaultAccess>(vaults.Count);
|
||||
|
||||
foreach (var vault in vaults)
|
||||
{
|
||||
if (vault.OwnerKind == VaultOwnerKind.Personal)
|
||||
{
|
||||
accessible.Add(new VaultAccess(vault, OwnerPermissions));
|
||||
continue;
|
||||
}
|
||||
|
||||
// The dictionary lookup cannot miss — the query filtered on the same set — but a role
|
||||
// that somehow is not there must not become a permissive default.
|
||||
if (vault.TeamId is { } teamId && roles.TryGetValue(teamId, out var role))
|
||||
{
|
||||
accessible.Add(new VaultAccess(vault, ForRole(role)));
|
||||
}
|
||||
}
|
||||
|
||||
return accessible;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a team role onto vault permissions.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Union-only, with no Deny: evaluation stays monotonic and testable, and restriction is
|
||||
/// expressed by granting narrowly. See <see cref="PermissionFlags"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="PermissionFlags.Connect"/> rides along with Read for every role that has it,
|
||||
/// because it is a user-interface hint rather than a boundary — a role that could read a private
|
||||
/// key but was refused Connect would be describing a restriction this architecture cannot
|
||||
/// enforce. Granting it to a viewer is honest about that; withholding it would not be.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Owner and Admin resolve identically here on purpose. What separates them is what they may do
|
||||
/// to the <em>team</em> — appoint owners, delete it — which is not a vault permission and is
|
||||
/// checked where those operations live.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static PermissionFlags ForRole(TeamRole role) => role switch
|
||||
{
|
||||
TeamRole.Viewer => PermissionFlags.Read | PermissionFlags.Connect,
|
||||
TeamRole.Member => PermissionFlags.Read | PermissionFlags.Connect | PermissionFlags.Write,
|
||||
TeamRole.Admin or TeamRole.Owner => OwnerPermissions,
|
||||
|
||||
// Unspecified, or a value written by a newer server. Failing closed is the only safe
|
||||
// direction for a role this build does not understand.
|
||||
_ => PermissionFlags.None,
|
||||
};
|
||||
|
||||
/// <remarks>
|
||||
/// Only an <see cref="MembershipStatus.Active"/> membership confers anything. An invited member
|
||||
/// has not accepted and a revoked one has been removed; neither is a state in which the server
|
||||
/// should be serving ciphertext.
|
||||
/// </remarks>
|
||||
private async Task<TeamRole?> FindRoleAsync(
|
||||
Guid userId,
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await database.TeamMemberships
|
||||
.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == userId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return membership?.Role;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// The public-key directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This is where a client gets the key it is about to wrap a vault key to, so its shape is a security
|
||||
/// decision rather than a convenience one. Two rules follow from that.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lookup by exact email, never by prefix.</b> There is no search, no wildcard and no listing of
|
||||
/// everybody. A caller has to already know the address, which keeps this from being a way to
|
||||
/// enumerate an organisation's staff out of a server that stores their addresses in plaintext.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Lookup by id is restricted to people the caller shares a team with.</b> Ids come from a member
|
||||
/// list the caller can already read, so nothing is hidden that they cannot reach another way — but an
|
||||
/// unrestricted id lookup would turn a leaked id from any source into a directory hit.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this returns is <em>evidence</em>, not authority. A client must check the identity-provider
|
||||
/// binding, compare against any fingerprint it has pinned, and confirm the key log head before
|
||||
/// wrapping anything. Trusting the directory's word is the one mistake that undoes end-to-end
|
||||
/// encryption entirely; see ADR 0001 and <c>DirectoryEntry</c>'s own remarks.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class DirectoryService(DodoDbContext database)
|
||||
{
|
||||
/// <summary>Looks a user up by exact email address.</summary>
|
||||
internal async Task<IReadOnlyList<DirectoryEntry>> FindByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalised = email.Trim();
|
||||
|
||||
if (normalised.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
// The email column is citext, so this comparison is case-insensitive in the database and the
|
||||
// partial unique index on it means at most one row can match. Written as a list anyway
|
||||
// because the response shape must not have to change if a second issuer ever shares one.
|
||||
var users = await database.Users
|
||||
.Where(u => u.Email == normalised
|
||||
&& u.DeletedAtUtc == null
|
||||
&& u.Status == UserStatus.Active)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Looks up accounts the caller shares an active team with.</summary>
|
||||
internal async Task<IReadOnlyList<DirectoryEntry>> FindTeammatesAsync(
|
||||
Guid callerId,
|
||||
IReadOnlyList<Guid> userIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (userIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var teamIds = await database.TeamMemberships
|
||||
.Where(m => m.UserId == callerId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.Select(m => m.TeamId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (teamIds.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var visible = await database.TeamMemberships
|
||||
.Where(m => teamIds.Contains(m.TeamId)
|
||||
&& userIds.Contains(m.UserId)
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.Select(m => m.UserId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var users = await database.Users
|
||||
.Where(u => visible.Contains(u.Id) && u.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return await BuildAsync(users, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A user with no current key is dropped rather than returned with empty key fields. The entry
|
||||
/// exists to be wrapped to, and one carrying no key is something a caller would have to remember
|
||||
/// to check for — which is the kind of check that gets forgotten exactly once.
|
||||
/// </remarks>
|
||||
private async Task<IReadOnlyList<DirectoryEntry>> BuildAsync(
|
||||
List<UserAccount> users,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (users.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var userIds = users.Select(u => u.Id).ToArray();
|
||||
|
||||
var keys = await database.UserKeys
|
||||
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// The log position of the statement that introduced each key, so a client can compare what
|
||||
// it is told here against the append-only chain rather than taking this response on trust.
|
||||
var keyIds = keys.Select(k => k.UserId).ToArray();
|
||||
|
||||
var sequences = await database.KeyLog
|
||||
.Where(e => keyIds.Contains(e.UserId))
|
||||
.GroupBy(e => new { e.UserId, e.Generation })
|
||||
.Select(g => new { g.Key.UserId, g.Key.Generation, Sequence = g.Min(e => e.Sequence) })
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var entries = new List<DirectoryEntry>(users.Count);
|
||||
|
||||
foreach (var user in users)
|
||||
{
|
||||
var key = keys.Find(k => k.UserId == user.Id);
|
||||
|
||||
if (key is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var sequence = sequences
|
||||
.Find(s => s.UserId == user.Id && s.Generation == key.Generation)?
|
||||
.Sequence ?? 0;
|
||||
|
||||
entries.Add(new DirectoryEntry(
|
||||
UserId: user.Id,
|
||||
Email: user.Email,
|
||||
DisplayName: user.DisplayName,
|
||||
EncryptionPublicKey: key.EncryptionPublicKey,
|
||||
SigningPublicKey: key.SigningPublicKey,
|
||||
Fingerprint: key.FingerprintSha256,
|
||||
KeyGeneration: key.Generation,
|
||||
KeyLogSequence: sequence));
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,117 @@ internal sealed class EnrollEndpoint(ICurrentUserContext currentUser, Enrollment
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up the public keys a vault key can be wrapped to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A GET with query parameters rather than a request DTO, because <c>BodyOnlyRequestBinder</c> binds
|
||||
/// bodies and nothing else — deliberately, so that a query string can never overwrite a body field —
|
||||
/// and this call has no body to speak of. The parameters are read one at a time, as route values are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Exactly one of <c>email</c> and <c>userId</c> is expected. They are separate parameters rather than
|
||||
/// one polymorphic term because they answer to different rules: an email may name anybody enrolled
|
||||
/// here, an id only somebody the caller shares a team with. See <see cref="DirectoryService"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class LookupDirectoryEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
DirectoryService directory)
|
||||
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/directory");
|
||||
|
||||
// Enrolled. The answer exists to be wrapped to, and a caller with no identity key of their
|
||||
// own has nothing to wrap and no signature to attribute it with.
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("LookupDirectory")
|
||||
.WithSummary("Looks up a user's published identity keys, by exact email or by id.")
|
||||
.WithTags("Identity"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<IReadOnlyList<DirectoryEntry>>, ProblemHttpResult>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var email = HttpContext.Request.Query["email"].ToString();
|
||||
var rawUserId = HttpContext.Request.Query["userId"].ToString();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return TypedResults.Ok(await directory.FindByEmailAsync(email, ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
if (Guid.TryParse(rawUserId, out var userId))
|
||||
{
|
||||
return TypedResults.Ok(
|
||||
await directory.FindTeammatesAsync(user.Id, [userId], ct).ConfigureAwait(false));
|
||||
}
|
||||
|
||||
// An empty result would be indistinguishable from "nobody has that address", which is a
|
||||
// different fact and one a client would go on to act on.
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest,
|
||||
ProblemCodes.MalformedRequest,
|
||||
"Supply either an exact 'email' or a 'userId'. This directory has no search.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serves the append-only key log, so a client can verify a public key rather than trust one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Paged with <c>after</c> and <c>limit</c> on the query string, read one at a time as route values
|
||||
/// are — see <see cref="LookupDirectoryEndpoint"/> for why this endpoint has no request DTO.
|
||||
/// </remarks>
|
||||
internal sealed class ReadKeyLogEndpoint(KeyLogService keyLog)
|
||||
: EndpointWithoutRequest<Ok<KeyLogPage>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/keylog");
|
||||
|
||||
// Enrolled rather than authenticated, matching the directory it exists to check. Nothing here
|
||||
// is secret, but a caller with no key of their own has nothing to verify against.
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ReadKeyLog")
|
||||
.WithSummary("Reads the append-only key log, with its current head.")
|
||||
.WithTags("Identity"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Ok<KeyLogPage>> ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
// A malformed value reads as "from the beginning" rather than as an error. The log is public
|
||||
// and ordered, so the worst a bad cursor costs is a larger response — and a 400 here would
|
||||
// make a client's own paging bug look like a server refusal.
|
||||
_ = long.TryParse(
|
||||
HttpContext.Request.Query["after"],
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var after);
|
||||
|
||||
int? limit = int.TryParse(
|
||||
HttpContext.Request.Query["limit"],
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out var parsed)
|
||||
? parsed
|
||||
: null;
|
||||
|
||||
return TypedResults.Ok(await keyLog.ReadAsync(after, limit, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Registers a device key so this machine can unlock without the passphrase.</summary>
|
||||
/// <remarks>
|
||||
/// 200 rather than 201, for the reason enrollment gives: re-registering the same public key returns the
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Serves the append-only key log.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Readable by every enrolled caller, in full. That is the point of it: a log only one party can read
|
||||
/// proves nothing, and the whole mechanism is that independent clients compare what they were shown.
|
||||
/// Nothing here is secret — public keys, signatures over them, and hashes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server never edits this table and this service never writes to it. Appends happen in exactly
|
||||
/// one place, under a deployment-wide advisory lock, inside the enrollment transaction; see
|
||||
/// <see cref="EnrollmentService"/> and docs/crypto.md §7.2 for why serialising them is load-bearing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class KeyLogService(DodoDbContext database)
|
||||
{
|
||||
/// <summary>Largest page served, whatever a caller asks for.</summary>
|
||||
/// <remarks>
|
||||
/// A client verifying the chain has to read every entry in order, so paging is a transfer-size
|
||||
/// concern rather than a filter. The cap is generous because skipping entries is not an option:
|
||||
/// a gap breaks the link and the verification fails, correctly, on data that was fine.
|
||||
/// </remarks>
|
||||
private const int MaxPageSize = 500;
|
||||
|
||||
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
|
||||
internal async Task<KeyLogPage> ReadAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var take = Math.Clamp(limit ?? MaxPageSize, 1, MaxPageSize);
|
||||
|
||||
var entries = await database.KeyLog
|
||||
.Where(e => e.Sequence > afterSequence)
|
||||
.OrderBy(e => e.Sequence)
|
||||
|
||||
// One more than asked for, so "is there another page" is answered by what came back
|
||||
// rather than by a second count that could disagree with it.
|
||||
.Take(take + 1)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var hasMore = entries.Count > take;
|
||||
|
||||
if (hasMore)
|
||||
{
|
||||
entries.RemoveAt(entries.Count - 1);
|
||||
}
|
||||
|
||||
var head = await database.KeyLog
|
||||
.OrderByDescending(e => e.Sequence)
|
||||
.Select(e => new { e.Sequence, e.Hash })
|
||||
.FirstOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new KeyLogPage(
|
||||
Entries:
|
||||
[
|
||||
.. entries.Select(e => new KeyLogRecord(
|
||||
e.Sequence,
|
||||
e.UserId,
|
||||
e.Generation,
|
||||
e.EncryptionPublicKey,
|
||||
e.SigningPublicKey,
|
||||
e.StatementSignature,
|
||||
e.PreviousHash,
|
||||
e.Hash,
|
||||
e.CreatedAtUtc)),
|
||||
],
|
||||
|
||||
HeadSequence: head?.Sequence ?? 0,
|
||||
|
||||
// The genesis predecessor for an empty log, which is the same value the first entry will
|
||||
// record. A client comparing heads therefore needs no special case for "nothing yet".
|
||||
Head: head?.Hash ?? KeyLogChain.CreateGenesisPreviousHash(),
|
||||
|
||||
HasMore: hasMore);
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ internal static class ItemKinds
|
||||
new[]
|
||||
{
|
||||
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
|
||||
new HostGroupKind(), new SnippetKind(),
|
||||
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
||||
}.ToDictionary(kind => kind.WireType);
|
||||
|
||||
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
||||
@@ -129,6 +131,20 @@ internal sealed class HostKind : IItemKind
|
||||
/// Mirrors the database CHECK so a bad request is a clear 200-with-Invalid rather than a constraint
|
||||
/// violation surfacing as a 500.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The group check comes <em>first</em>, ahead of the relay branch, and that placement is the point: the
|
||||
/// relay branch returns early on its happy path, so a check placed after it would apply to non-relay
|
||||
/// hosts only — leaving the one field this refusal exists for reachable by exactly the hosts most likely
|
||||
/// to carry it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>SyncPlaintextFields.GroupId</c> is part of a frozen wire contract and cannot be removed from it, so
|
||||
/// refusing it here is what actually keeps the value out of the database. The column it used to be copied
|
||||
/// into was dropped when groups landed; see <see cref="VaultHostGroup"/> for why membership travels inside
|
||||
/// the payload instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
@@ -136,6 +152,12 @@ internal sealed class HostKind : IItemKind
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.GroupId is not null)
|
||||
{
|
||||
error = "A host's group is inside its encrypted payload; the server does not store one.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.RelayEnabled)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fields.Hostname) || fields.Port is null)
|
||||
@@ -172,7 +194,6 @@ internal sealed class HostKind : IItemKind
|
||||
host.RelayEnabled = fields.RelayEnabled;
|
||||
host.Hostname = fields.RelayEnabled ? fields.Hostname : null;
|
||||
host.Port = fields.RelayEnabled ? fields.Port : null;
|
||||
host.GroupId = fields.GroupId;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -197,8 +218,7 @@ internal sealed class HostKind : IItemKind
|
||||
return new SyncPlaintextFields(
|
||||
RelayEnabled: host.RelayEnabled,
|
||||
Hostname: host.Hostname,
|
||||
Port: host.Port,
|
||||
GroupId: host.GroupId);
|
||||
Port: host.Port);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -487,3 +507,483 @@ internal sealed class KnownHostKeyKind : IItemKind
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Host groups: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// The kind that closes a hole rather than opening one. <c>SyncPlaintextFields</c> has carried a
|
||||
/// <c>GroupId</c> since the contract was frozen and <see cref="HostKind"/> used to copy it into a column;
|
||||
/// nothing ever sent one, and now nothing may. The group itself arrives here as ciphertext with no name the
|
||||
/// server can read, which is the same answer <see cref="KnownHostKeyKind"/> gives for the same reason.
|
||||
/// </remarks>
|
||||
internal sealed class HostGroupKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.HostGroup;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.HostGroup;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.HostGroups.SingleOrDefaultAsync(g => g.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.HostGroups
|
||||
.Where(g => g.VaultId == vaultId && ids.Contains(g.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var group = new VaultHostGroup { Id = id, VaultId = vaultId };
|
||||
|
||||
database.HostGroups.Add(group);
|
||||
|
||||
return group;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses every plaintext field there is, including the one named after this type.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>GroupId</c> on a group would be a parent pointer, and groups are flat — see
|
||||
/// <see cref="VaultHostGroup"/> for why nesting merged by a scalar three-way merge can produce a cycle
|
||||
/// nothing is able to repair. Refusing it here means a client that grows a tree cannot store one by
|
||||
/// accident.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A host group is not something the server dials.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.GroupId is not null || fields.ParentId is not null)
|
||||
{
|
||||
error = "Host groups are flat, and a group's name is inside its payload.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.PublicKeyFingerprint is not null)
|
||||
{
|
||||
error = "A host group has no public key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Snippets: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// A label column here would sort a list this server never draws, and the commands beside that label describe
|
||||
/// the estate as precisely as a list of hostnames would. So this kind is as strict as
|
||||
/// <see cref="CredentialKind"/>, and for the aggregation reason rather than the secrecy one.
|
||||
/// </remarks>
|
||||
internal sealed class SnippetKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.Snippet;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.Snippet;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.Snippets.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.Snippets
|
||||
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var snippet = new VaultSnippet { Id = id, VaultId = vaultId };
|
||||
|
||||
database.Snippets.Add(snippet);
|
||||
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/// <summary>Refuses every plaintext field there is.</summary>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A snippet is not something the server dials.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
||||
{
|
||||
error = "A snippet's contents, including anything it is scoped to, stay inside its payload.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.PublicKeyFingerprint is not null)
|
||||
{
|
||||
error = "A snippet has no public key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Connection log entries: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The strictest kind here, and the one where a plaintext column would have been most tempting: a
|
||||
/// <c>started_at</c> would let this server order and prune a log without any client's help. It gets none,
|
||||
/// because a timestamp column on this table is a record of when each user works, and the times are the
|
||||
/// interesting part of a connection log even when the hostnames are sealed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The server cannot enforce write-once, and does not pretend to.</b> That an entry is created and never
|
||||
/// updated is a client rule — see <see cref="VaultConnectionLogEntry"/> — and the shared write path would
|
||||
/// accept an upsert with a correct <c>expectedVersion</c> like any other. Adding a refusal here would be a
|
||||
/// guarantee about payload semantics this server cannot read.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ConnectionLogEntryKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.ConnectionLogEntry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.ConnectionLogEntry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.ConnectionLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.ConnectionLog
|
||||
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var entry = new VaultConnectionLogEntry { Id = id, VaultId = vaultId };
|
||||
|
||||
database.ConnectionLog.Add(entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>Refuses every plaintext field there is.</summary>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A log entry is not something the server dials; what was connected to stays encrypted.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
||||
{
|
||||
error = "A log entry names what it is about inside its payload.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
||||
{
|
||||
error = "A log entry carries no plaintext fields at all.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Activity log entries: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// As strict as <see cref="ConnectionLogEntryKind"/>. <c>SyncPlaintextFields.Kind</c> exists and would fit
|
||||
/// "which sort of item this entry is about" exactly, which is why it is refused by name: a column recording
|
||||
/// that a user created four SSH keys last Tuesday is a description of the keychain, assembled from facts
|
||||
/// that each look harmless.
|
||||
/// </remarks>
|
||||
internal sealed class ActivityLogEntryKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.ActivityLogEntry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.ActivityLogEntry;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.ActivityLog.SingleOrDefaultAsync(e => e.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.ActivityLog
|
||||
.Where(e => e.VaultId == vaultId && ids.Contains(e.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var entry = new VaultActivityLogEntry { Id = id, VaultId = vaultId };
|
||||
|
||||
database.ActivityLog.Add(entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// <summary>Refuses every plaintext field there is.</summary>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A log entry is not something the server dials.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
||||
{
|
||||
error = "Which item a log entry is about stays inside its payload.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
||||
{
|
||||
error = "A log entry carries no plaintext fields at all.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
/// <summary>Object stores: an envelope and nothing else.</summary>
|
||||
/// <remarks>
|
||||
/// As strict as <see cref="CredentialKind"/>, because it holds the same class of thing. A secret access key
|
||||
/// is a password; the endpoint beside it is, for everybody self-hosting, an address on their own network. The
|
||||
/// relay does not dial a bucket, so ADR 0004's one concession has no analogue here and there is nothing to
|
||||
/// weigh.
|
||||
/// </remarks>
|
||||
internal sealed class ObjectStoreKind : IItemKind
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public SyncEntityType WireType => SyncEntityType.ObjectStore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ChangeEntityType ChangeType => ChangeEntityType.ObjectStore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IVaultItem?> FindAsync(
|
||||
DodoDbContext database,
|
||||
Guid id,
|
||||
CancellationToken cancellationToken) =>
|
||||
await database.ObjectStores.SingleOrDefaultAsync(o => o.Id == id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
|
||||
DodoDbContext database,
|
||||
Guid vaultId,
|
||||
Guid[] ids,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.ObjectStores
|
||||
.Where(o => o.VaultId == vaultId && ids.Contains(o.Id))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
|
||||
{
|
||||
var store = new VaultObjectStore { Id = id, VaultId = vaultId };
|
||||
|
||||
database.ObjectStores.Add(store);
|
||||
|
||||
return store;
|
||||
}
|
||||
|
||||
/// <summary>Refuses every plaintext field there is.</summary>
|
||||
/// <remarks>
|
||||
/// The relay fields are refused although this type <em>does</em> hold an address, exactly as they are for
|
||||
/// a pinned host key: the address belongs in the ciphertext, and a client sending it here is either
|
||||
/// confused or trying to get the server to keep a list of where its users store data.
|
||||
/// </remarks>
|
||||
/// <inheritdoc />
|
||||
public bool ValidateFields(SyncPlaintextFields fields, out string error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fields);
|
||||
|
||||
error = string.Empty;
|
||||
|
||||
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
|
||||
{
|
||||
error = "A bucket is not something the server dials; its endpoint stays encrypted.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
||||
{
|
||||
error = "A bucket's contents are inside its payload.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
||||
{
|
||||
error = "A bucket carries no plaintext fields at all.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
|
||||
/// <inheritdoc />
|
||||
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void ClearFieldsOnDelete(IVaultItem item)
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>Creates a team, with the caller as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// 200 rather than 201, for the reason enrollment gives: the id is chosen by the client, so a retried
|
||||
/// request returns the identical team and there is no single moment of creation to point a Location
|
||||
/// header at.
|
||||
/// </remarks>
|
||||
internal sealed class CreateTeamEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: Endpoint<CreateTeamRequest, Results<Ok<TeamSummary>, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/teams");
|
||||
|
||||
// Enrolled, not merely authenticated. Somebody who has not published an identity key cannot
|
||||
// be wrapped a vault key, so a team they created would be one they could never share
|
||||
// anything into — and the flag they would hit instead is a 400 from the grant endpoint,
|
||||
// several steps later, about a request that was fine.
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("CreateTeam")
|
||||
.WithSummary("Creates a team, with the caller as its owner.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<TeamSummary>, ProblemHttpResult>> ExecuteAsync(
|
||||
CreateTeamRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
return TypedResults.Ok(await teams.CreateAsync(user, req, ct).ConfigureAwait(false));
|
||||
}
|
||||
catch (TeamSlugTakenException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status409Conflict, ProblemCodes.TeamSlugTaken, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lists the teams the caller belongs to.</summary>
|
||||
internal sealed class ListTeamsEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: EndpointWithoutRequest<Ok<IReadOnlyList<TeamSummary>>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/teams");
|
||||
|
||||
// Authenticated rather than enrolled: reading which teams you are in needs no key, and a
|
||||
// member who has just been added should be able to see that before they set a vault up.
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ListTeams")
|
||||
.WithSummary("Lists the teams the caller belongs to.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Ok<IReadOnlyList<TeamSummary>>> ExecuteAsync(CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(await teams.ListAsync(user, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lists a team's members.</summary>
|
||||
internal sealed class ListTeamMembersEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: EndpointWithoutRequest<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/teams/{teamId:guid}/members");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ListTeamMembers")
|
||||
.WithSummary("Lists a team's members.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<IReadOnlyList<TeamMemberSummary>>, NotFound>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
// 404 for a team that is not there and one the caller is not in, identically. See
|
||||
// VaultAccessService for why the two must not be distinguishable.
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(await teams.ListMembersAsync(teamId, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds a member to a team.</summary>
|
||||
internal sealed class AddTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: Endpoint<AddTeamMemberRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/teams/{teamId:guid}/members");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("AddTeamMember")
|
||||
.WithSummary("Adds a member to a team.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
AddTeamMemberRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
// 403 rather than 404 here: the team is visible to this caller, so refusing by name leaks
|
||||
// nothing and "you are not an admin" is a far more useful answer than "no such team".
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can add members.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var member = await teams.AddMemberAsync(user, teamId, req, ct).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(member);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
internal sealed class ChangeTeamMemberRoleEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: Endpoint<ChangeTeamMemberRoleRequest, Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
// PUT rather than PATCH. The body is the whole of what a role is, so this replaces it
|
||||
// outright and is idempotent; PATCH would promise a partial update of a single scalar.
|
||||
Put("/api/v1/teams/{teamId:guid}/members/{userId:guid}/role");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ChangeTeamMemberRole")
|
||||
.WithSummary("Changes a member's role.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<TeamMemberSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
ChangeTeamMemberRoleRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
var memberId = Route<Guid>("userId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can change roles.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var member = await teams
|
||||
.ChangeRoleAsync(user, teamId, memberId, req, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(member);
|
||||
}
|
||||
catch (LastTeamOwnerException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a member from a team.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A member may remove themselves — leaving a team needs nobody's permission — but not while they
|
||||
/// own it. Everyone else needs to be an admin.
|
||||
/// </remarks>
|
||||
internal sealed class RemoveTeamMemberEndpoint(ICurrentUserContext currentUser, TeamService teams)
|
||||
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/v1/teams/{teamId:guid}/members/{userId:guid}");
|
||||
|
||||
Policies(Auth.AuthenticatedPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("RemoveTeamMember")
|
||||
.WithSummary("Removes a member from a team, revoking their vault key grants.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
var memberId = Route<Guid>("userId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister && memberId != user.Id)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can remove other members.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await teams.RemoveMemberAsync(user, teamId, memberId, ct).ConfigureAwait(false);
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (LastTeamOwnerException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status409Conflict, ProblemCodes.LastTeamOwner, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by a team.</summary>
|
||||
internal sealed class CreateTeamVaultEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
TeamService teams,
|
||||
VaultGrantService grants)
|
||||
: Endpoint<CreateTeamVaultRequest, Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/teams/{teamId:guid}/vaults");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("CreateTeamVault")
|
||||
.WithSummary("Creates a vault owned by a team, with the creator's key grant.")
|
||||
.WithTags("Teams"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<VaultSummary>, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CreateTeamVaultRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var teamId = Route<Guid>("teamId");
|
||||
|
||||
var access = await teams.ResolveAsync(user.Id, teamId, ct).ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.CanAdminister)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"Only an admin or the owner of this team can create a vault in it.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var vault = await grants
|
||||
.CreateTeamVaultAsync(user, access.Team!, req, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return TypedResults.Ok(vault);
|
||||
}
|
||||
catch (VaultGrantInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
||||
}
|
||||
catch (TeamInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidTeam, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// A team create or membership change was structurally unacceptable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The message is returned to the caller. Keep it about the shape of their own request, and never
|
||||
/// about accounts or teams they cannot already see — "no such user" is safe when the caller supplied
|
||||
/// the id from a directory lookup they just made, and is an enumeration oracle everywhere else.
|
||||
/// </remarks>
|
||||
internal sealed class TeamInvalidException(string message) : Exception(message);
|
||||
|
||||
/// <summary>The requested slug is already in use.</summary>
|
||||
/// <remarks>
|
||||
/// Its own type because it is the one create failure the caller could not have predicted from their
|
||||
/// own input, and the only one whose remedy is choosing a different value rather than fixing one.
|
||||
/// </remarks>
|
||||
internal sealed class TeamSlugTakenException(string message) : Exception(message);
|
||||
|
||||
/// <summary>The change would leave a team with no owner.</summary>
|
||||
/// <remarks>
|
||||
/// Refused rather than allowed: a team with no owner has nobody who can appoint one, so the only
|
||||
/// route back would be an operator editing the database by hand.
|
||||
/// </remarks>
|
||||
internal sealed class LastTeamOwnerException(string message) : Exception(message);
|
||||
|
||||
/// <summary>
|
||||
/// A vault key grant was rejected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Never about the wrapped key's contents. The server cannot open it, so a grant sealing garbage is
|
||||
/// accepted here and fails at the recipient as a tag failure, with the signature naming who issued
|
||||
/// it. See docs/crypto.md §6.
|
||||
/// </remarks>
|
||||
internal sealed class VaultGrantInvalidException(string message) : Exception(message);
|
||||
@@ -0,0 +1,68 @@
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated log events for teams, membership and vault key grants.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ids, roles and outcomes only. Never a wrapped key, a signature or a fingerprint: the sharing graph
|
||||
/// is already visible to the operator (docs/crypto.md §10) and there is nothing to gain by adding key
|
||||
/// material to what a log aggregator keeps.
|
||||
/// </remarks>
|
||||
internal static partial class TeamLog
|
||||
{
|
||||
[LoggerMessage(
|
||||
EventId = 2101,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Created team {TeamId} for user {UserId}.")]
|
||||
internal static partial void TeamCreated(ILogger logger, Guid teamId, Guid userId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2102,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Added user {MemberId} to team {TeamId} as {Role}, by {ActorId}.")]
|
||||
internal static partial void MemberAdded(
|
||||
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2103,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Changed user {MemberId} in team {TeamId} to {Role}, by {ActorId}.")]
|
||||
internal static partial void MemberRoleChanged(
|
||||
ILogger logger, Guid teamId, Guid memberId, Domain.TeamRole role, Guid actorId);
|
||||
|
||||
/// <remarks>
|
||||
/// Warning rather than information, and it names the grant count. Removal is the operation whose
|
||||
/// consequences are least like what the word implies — it blocks future reads and returns nothing
|
||||
/// already downloaded — so it is the one worth being able to find in a log afterwards.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2104,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Removed user {MemberId} from team {TeamId} by {ActorId}; revoked {GrantCount} vault "
|
||||
+ "key grant(s). Vaults are flagged for rekey; already-downloaded data is unaffected.")]
|
||||
internal static partial void MemberRemoved(
|
||||
ILogger logger, Guid teamId, Guid memberId, Guid actorId, int grantCount);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2105,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Created team vault {VaultId} for team {TeamId}, by {ActorId}.")]
|
||||
internal static partial void TeamVaultCreated(
|
||||
ILogger logger, Guid vaultId, Guid teamId, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2106,
|
||||
Level = LogLevel.Information,
|
||||
Message = "Issued a key grant on vault {VaultId} generation {KeyGeneration} to {RecipientId}, "
|
||||
+ "by {ActorId}.")]
|
||||
internal static partial void GrantIssued(
|
||||
ILogger logger, Guid vaultId, int keyGeneration, Guid recipientId, Guid actorId);
|
||||
|
||||
[LoggerMessage(
|
||||
EventId = 2107,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Revoked the key grant on vault {VaultId} held by {RecipientId}, by {ActorId}. "
|
||||
+ "Blocks future reads only; see ADR 0001.")]
|
||||
internal static partial void GrantRevoked(
|
||||
ILogger logger, Guid vaultId, Guid recipientId, Guid actorId);
|
||||
}
|
||||
@@ -0,0 +1,745 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>The result of a team access check.</summary>
|
||||
/// <param name="Team">The team, when the caller is an active member.</param>
|
||||
/// <param name="Role">The caller's role.</param>
|
||||
internal readonly record struct TeamAccess(Team? Team, TeamRole Role)
|
||||
{
|
||||
/// <summary>Whether the caller is in this team at all.</summary>
|
||||
public bool Granted => Team is not null;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the caller may manage members and vaults.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The team-level counterpart of <c>PermissionFlags.Admin</c>, and deliberately not derived from
|
||||
/// it: those flags describe a vault, and adding a member is not an operation on any vault.
|
||||
/// </remarks>
|
||||
public bool CanAdminister => Role is TeamRole.Admin or TeamRole.Owner;
|
||||
|
||||
/// <summary>Denied access.</summary>
|
||||
public static TeamAccess Denied => new(null, TeamRole.Unspecified);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teams and their membership.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Membership is authorization; a key grant is access.</b> Everything in this class moves rows
|
||||
/// that decide what the <em>server</em> will serve. None of it can make a vault readable, because
|
||||
/// making a vault readable means wrapping its key to somebody's public key and only a client holding
|
||||
/// that key can do it. Adding a member is therefore two deliberate steps, and the interface says so:
|
||||
/// add them here, then share the vault key from a machine that has one. Collapsing the two would
|
||||
/// require the server to hold a key, which is the one thing this design is built to avoid.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The reverse direction is the honest half of the same split. Removing a member revokes their
|
||||
/// grants and flags every team vault for rekey, and that blocks <em>future</em> reads only. Anything
|
||||
/// already on their laptop is already gone; the real remediation is rotating the SSH credential. See
|
||||
/// ADR 0001, and note that this class deliberately does not offer a "revoke access" verb that would
|
||||
/// imply more than it delivers.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class TeamService(
|
||||
DodoDbContext database,
|
||||
TimeProvider clock,
|
||||
ILogger<TeamService> logger)
|
||||
{
|
||||
/// <summary>Longest acceptable slug. Matches the column.</summary>
|
||||
private const int MaxSlugLength = 128;
|
||||
|
||||
/// <summary>Longest acceptable display name. Matches the column.</summary>
|
||||
private const int MaxNameLength = 256;
|
||||
|
||||
/// <summary>Longest acceptable description. Matches the column.</summary>
|
||||
private const int MaxDescriptionLength = 2048;
|
||||
|
||||
/// <summary>Creates a team, with the caller as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// Idempotent on the client-chosen id, exactly as enrollment is: a request whose response was
|
||||
/// lost can be re-sent verbatim and returns the same team rather than creating a second one under
|
||||
/// a name the user meant to type once. A different body under the same id is a client that has
|
||||
/// lost track of its own state and is refused rather than silently reinterpreted.
|
||||
/// </remarks>
|
||||
internal async Task<TeamSummary> CreateAsync(
|
||||
UserAccount user,
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var name = RequireText(request.Name, nameof(request.Name), MaxNameLength);
|
||||
var slug = RequireSlug(request.Slug);
|
||||
var description = OptionalText(request.Description, MaxDescriptionLength);
|
||||
|
||||
if (request.TeamId == Guid.Empty)
|
||||
{
|
||||
throw new TeamInvalidException("A team id is required. Generate a UUIDv7 on the client.");
|
||||
}
|
||||
|
||||
var existing = await database.Teams
|
||||
.SingleOrDefaultAsync(t => t.Id == request.TeamId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
return await ResolveExistingAsync(user, existing, name, slug, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var team = AddTeamWithOwner(user, request.TeamId, name, slug, description);
|
||||
|
||||
try
|
||||
{
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||
{
|
||||
// The partial unique index on slug. Reported as its own code because it is the one
|
||||
// failure the caller could not have foreseen from their own input.
|
||||
throw new TeamSlugTakenException(
|
||||
$"The slug '{slug}' is already in use. Choose another.");
|
||||
}
|
||||
|
||||
TeamLog.TeamCreated(logger, team.Id, user.Id);
|
||||
|
||||
return new TeamSummary(
|
||||
team.Id, team.Name, team.Slug, team.Description,
|
||||
TeamMemberRole.Owner, MemberCount: 1, VaultCount: 0, team.CreatedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the team row and the creator's owner membership.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two together, never one: a team with no members has nobody who can add any, and the row
|
||||
/// would have to be found and fixed by hand.
|
||||
/// </remarks>
|
||||
private Team AddTeamWithOwner(
|
||||
UserAccount user,
|
||||
Guid teamId,
|
||||
string name,
|
||||
string slug,
|
||||
string? description)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var team = new Team
|
||||
{
|
||||
Id = teamId,
|
||||
Name = name,
|
||||
Slug = slug,
|
||||
Description = description,
|
||||
CreatedByUserId = user.Id,
|
||||
CreatedAtUtc = now,
|
||||
};
|
||||
|
||||
database.Teams.Add(team);
|
||||
|
||||
database.TeamMemberships.Add(new TeamMembership
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TeamId = team.Id,
|
||||
UserId = user.Id,
|
||||
Role = TeamRole.Owner,
|
||||
Status = MembershipStatus.Active,
|
||||
JoinedAtUtc = now,
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
|
||||
return team;
|
||||
}
|
||||
|
||||
/// <summary>Lists the teams the caller is an active member of.</summary>
|
||||
internal async Task<IReadOnlyList<TeamSummary>> ListAsync(
|
||||
UserAccount user,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => m.UserId == user.Id
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (memberships.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var teamIds = memberships.Select(m => m.TeamId).ToArray();
|
||||
|
||||
var teams = await database.Teams
|
||||
.Where(t => teamIds.Contains(t.Id) && t.DeletedAtUtc == null)
|
||||
.OrderBy(t => t.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var memberCounts = await database.TeamMemberships
|
||||
.Where(m => teamIds.Contains(m.TeamId)
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null)
|
||||
.GroupBy(m => m.TeamId)
|
||||
.Select(g => new { TeamId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var vaultCounts = await database.Vaults
|
||||
.Where(v => v.OwnerKind == VaultOwnerKind.Team
|
||||
&& v.TeamId != null
|
||||
&& teamIds.Contains(v.TeamId.Value)
|
||||
&& v.DeletedAtUtc == null)
|
||||
.GroupBy(v => v.TeamId!.Value)
|
||||
.Select(g => new { TeamId = g.Key, Count = g.Count() })
|
||||
.ToDictionaryAsync(x => x.TeamId, x => x.Count, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return
|
||||
[
|
||||
.. teams.Select(team => new TeamSummary(
|
||||
team.Id,
|
||||
team.Name,
|
||||
team.Slug,
|
||||
team.Description,
|
||||
ToContract(memberships.Find(m => m.TeamId == team.Id)!.Role),
|
||||
memberCounts.GetValueOrDefault(team.Id),
|
||||
vaultCounts.GetValueOrDefault(team.Id),
|
||||
team.CreatedAtUtc)),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lists a team's members.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Available to every member, not only to admins. Whoever is about to be handed a vault key needs
|
||||
/// to know who else already holds one, and a directory that only administrators can read makes
|
||||
/// the sharing graph less visible to the people it is about than it is to the operator — who can
|
||||
/// read it straight out of the database either way.
|
||||
/// </remarks>
|
||||
internal async Task<IReadOnlyList<TeamMemberSummary>> ListMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var memberships = await database.TeamMemberships
|
||||
.Where(m => m.TeamId == teamId && m.DeletedAtUtc == null)
|
||||
.Include(m => m.User)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (memberships.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var userIds = memberships.Select(m => m.UserId).ToArray();
|
||||
|
||||
var enrolled = await database.UserKeys
|
||||
.Where(k => userIds.Contains(k.UserId) && k.IsCurrent)
|
||||
.Select(k => k.UserId)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var enrolledIds = enrolled.ToHashSet();
|
||||
|
||||
return
|
||||
[
|
||||
.. memberships
|
||||
.OrderByDescending(m => m.Role)
|
||||
.ThenBy(m => m.CreatedAtUtc)
|
||||
.Select(m => new TeamMemberSummary(
|
||||
m.UserId,
|
||||
m.User?.Email,
|
||||
m.User?.DisplayName,
|
||||
ToContract(m.Role),
|
||||
ToContract(m.Status),
|
||||
enrolledIds.Contains(m.UserId),
|
||||
m.JoinedAtUtc)),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Adds a member, or reactivates one who was removed.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The role may not be <see cref="TeamMemberRole.Owner"/>. Ownership is sole, so granting it to
|
||||
/// somebody else is a transfer rather than an addition — a different operation with a different
|
||||
/// confirmation, and not one M3 offers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Re-adding a removed member reactivates the original row rather than inserting a second one,
|
||||
/// which is what keeps historic audit entries resolvable to one membership. It does <em>not</em>
|
||||
/// restore their revoked key grants: those were wrapped to a generation the vault has since been
|
||||
/// flagged to leave behind, and a member holding Share has to wrap the key afresh.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<TeamMemberSummary> AddMemberAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var role = ToDomain(request.Role);
|
||||
|
||||
if (role is TeamRole.Unspecified or TeamRole.Owner)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"Add a member as viewer, member or admin. Ownership is sole and is not transferred "
|
||||
+ "by adding somebody.");
|
||||
}
|
||||
|
||||
var target = await database.Users
|
||||
.SingleOrDefaultAsync(
|
||||
u => u.Id == request.UserId && u.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
|
||||
// Safe to be specific: the caller supplied this id from a directory lookup they just
|
||||
// made, so it confirms nothing they did not already know.
|
||||
?? throw new TeamInvalidException(
|
||||
"No such account on this server. A member has to sign in here once before they can "
|
||||
+ "be added — that is what creates the account and publishes the key a vault would "
|
||||
+ "be shared with.");
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
var membership = await database.TeamMemberships
|
||||
.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId && m.UserId == target.Id && m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (membership is null)
|
||||
{
|
||||
membership = new TeamMembership
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
TeamId = teamId,
|
||||
UserId = target.Id,
|
||||
InvitedByUserId = actor.Id,
|
||||
CreatedAtUtc = now,
|
||||
};
|
||||
|
||||
database.TeamMemberships.Add(membership);
|
||||
}
|
||||
else if (membership.Status == MembershipStatus.Active)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"That account is already a member of this team. Change their role instead.");
|
||||
}
|
||||
|
||||
membership.Role = role;
|
||||
membership.Status = MembershipStatus.Active;
|
||||
membership.JoinedAtUtc = now;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.MemberAdded(logger, teamId, target.Id, role, actor.Id);
|
||||
|
||||
return await DescribeAsync(target, membership, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Enrollment is looked up rather than inferred, because it is the one field on a member row that
|
||||
/// is about them and not about the membership: somebody can be added on Monday and set their
|
||||
/// vault up on Tuesday, and the interface has to stop offering to share with them in between.
|
||||
/// </remarks>
|
||||
private async Task<TeamMemberSummary> DescribeAsync(
|
||||
UserAccount user,
|
||||
TeamMembership membership,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var isEnrolled = await database.UserKeys
|
||||
.AnyAsync(k => k.UserId == user.Id && k.IsCurrent, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new TeamMemberSummary(
|
||||
user.Id,
|
||||
user.Email,
|
||||
user.DisplayName,
|
||||
ToContract(membership.Role),
|
||||
ToContract(membership.Status),
|
||||
isEnrolled,
|
||||
membership.JoinedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
internal async Task<TeamMemberSummary> ChangeRoleAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var role = ToDomain(request.Role);
|
||||
|
||||
if (role is TeamRole.Unspecified or TeamRole.Owner)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"A member may be made a viewer, a member or an admin. Ownership is sole and is not "
|
||||
+ "granted this way.");
|
||||
}
|
||||
|
||||
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Demoting the owner is what would leave the team ownerless, and there is no transfer to
|
||||
// do it through yet. Refused with the code a client can act on rather than a bare 400.
|
||||
if (membership.Role == TeamRole.Owner)
|
||||
{
|
||||
throw new LastTeamOwnerException(
|
||||
"This team's owner cannot be demoted, because nothing can appoint a replacement yet.");
|
||||
}
|
||||
|
||||
membership.Role = role;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.MemberRoleChanged(logger, teamId, memberId, role, actor.Id);
|
||||
|
||||
// The account cannot be missing — a membership has a foreign key to it — but the query is
|
||||
// written to tolerate it rather than to assert, because a null here would become an
|
||||
// exception on a change that has already been committed.
|
||||
var user = await database.Users
|
||||
.SingleOrDefaultAsync(u => u.Id == memberId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return user is null
|
||||
? new TeamMemberSummary(
|
||||
memberId, null, null, ToContract(role), ToContract(membership.Status), false,
|
||||
membership.JoinedAtUtc)
|
||||
: await DescribeAsync(user, membership, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a member, revoking every vault key grant they hold from this team.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One transaction, because the two halves are not separable: a membership revoked without its
|
||||
/// grants leaves a departed member holding a key the server will happily keep serving, and grants
|
||||
/// revoked without the membership leaves an active member whose vaults have silently stopped
|
||||
/// opening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every affected vault is flagged <c>RekeyRequired</c> rather than rekeyed. A rekey re-wraps
|
||||
/// every item's data key under a new vault key and can only be performed by a client that holds
|
||||
/// the current one; the server can record that one is owed and nothing more. That is M5's key
|
||||
/// rotation, and until it lands the flag is what the interface reads to say so out loud.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task RemoveMemberAsync(
|
||||
UserAccount actor,
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await RequireMembershipAsync(teamId, memberId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (membership.Role == TeamRole.Owner)
|
||||
{
|
||||
throw new LastTeamOwnerException(
|
||||
"This team's owner cannot be removed. Ownership transfer is not implemented, so "
|
||||
+ "removing them would leave the team with nobody who can manage it.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
|
||||
var revoked = await strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
membership.Status = MembershipStatus.Revoked;
|
||||
membership.DeletedAtUtc = now;
|
||||
|
||||
var count = await RevokeTeamGrantsAsync(teamId, memberId, now, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return count;
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
TeamLog.MemberRemoved(logger, teamId, memberId, actor.Id, revoked);
|
||||
}
|
||||
|
||||
/// <summary>Revokes one user's grants on every vault a team owns, and flags each for rekey.</summary>
|
||||
private async Task<int> RevokeTeamGrantsAsync(
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var vaults = await database.Vaults
|
||||
.Where(v => v.TeamId == teamId
|
||||
&& v.OwnerKind == VaultOwnerKind.Team
|
||||
&& v.DeletedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (vaults.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var vaultIds = vaults.Select(v => v.Id).ToArray();
|
||||
|
||||
var grants = await database.VaultKeyGrants
|
||||
.Where(g => vaultIds.Contains(g.VaultId)
|
||||
&& g.RecipientUserId == memberId
|
||||
&& g.RevokedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
grant.State = GrantState.Revoked;
|
||||
grant.RevokedAtUtc = now;
|
||||
}
|
||||
|
||||
// Flagged whether or not this member held a grant. Somebody who was a member without a key
|
||||
// still saw the vault's existence, its item count and its plaintext columns, and the vault's
|
||||
// key is what a rekey would change — so "they never had a grant" is not a reason to leave the
|
||||
// flag clear.
|
||||
foreach (var vault in vaults)
|
||||
{
|
||||
vault.RekeyRequired = true;
|
||||
vault.RekeyReason = RekeyReason.MemberRemoved;
|
||||
vault.UpdatedAtUtc = now;
|
||||
}
|
||||
|
||||
return grants.Count;
|
||||
}
|
||||
|
||||
/// <summary>Reads the caller's own membership, for authorization checks.</summary>
|
||||
internal Task<TeamMembership?> FindActiveMembershipAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
database.TeamMemberships.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == userId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves what the caller may do with a team.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Answers <see cref="TeamAccess.Denied"/> identically for a team that does not exist and one the
|
||||
/// caller is not in, for the reason <c>VaultAccessService</c> gives: a distinct "exists but
|
||||
/// forbidden" is an oracle for other tenants' team ids.
|
||||
/// </remarks>
|
||||
internal async Task<TeamAccess> ResolveAsync(
|
||||
Guid userId,
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var team = await database.Teams
|
||||
.SingleOrDefaultAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (team is null)
|
||||
{
|
||||
return TeamAccess.Denied;
|
||||
}
|
||||
|
||||
var membership = await FindActiveMembershipAsync(teamId, userId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return membership is null ? TeamAccess.Denied : new TeamAccess(team, membership.Role);
|
||||
}
|
||||
|
||||
private async Task<TeamMembership> RequireMembershipAsync(
|
||||
Guid teamId,
|
||||
Guid memberId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await database.TeamMemberships
|
||||
.SingleOrDefaultAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == memberId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return membership
|
||||
?? throw new TeamInvalidException("That account is not an active member of this team.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A retry is the same id with the same name and slug, from the account that owns it. Anything
|
||||
/// else under an id that is already taken is refused: silently returning somebody else's team
|
||||
/// would be an existence oracle, and returning a differently-named one would tell a client its
|
||||
/// rename succeeded when nothing changed.
|
||||
/// </remarks>
|
||||
private async Task<TeamSummary> ResolveExistingAsync(
|
||||
UserAccount user,
|
||||
Team existing,
|
||||
string name,
|
||||
string slug,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var membership = await FindActiveMembershipAsync(existing.Id, user.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var isRetry = membership?.Role == TeamRole.Owner
|
||||
&& existing.DeletedAtUtc == null
|
||||
&& string.Equals(existing.Name, name, StringComparison.Ordinal)
|
||||
&& string.Equals(existing.Slug, slug, StringComparison.Ordinal);
|
||||
|
||||
if (!isRetry)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"That team id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
var memberCount = await database.TeamMemberships
|
||||
.CountAsync(
|
||||
m => m.TeamId == existing.Id
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var vaultCount = await database.Vaults
|
||||
.CountAsync(
|
||||
v => v.TeamId == existing.Id && v.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new TeamSummary(
|
||||
existing.Id, existing.Name, existing.Slug, existing.Description,
|
||||
TeamMemberRole.Owner, memberCount, vaultCount, existing.CreatedAtUtc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a slug.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lowercase ASCII letters, digits and single hyphens, not starting or ending with one. Narrow on
|
||||
/// purpose: the column is <c>citext</c>, so a slug differing only in case is the same slug, and a
|
||||
/// value that renders differently from how it compares is how two teams end up looking distinct
|
||||
/// in a list and colliding on insert.
|
||||
/// </remarks>
|
||||
private static string RequireSlug(string? value)
|
||||
{
|
||||
var slug = (value ?? string.Empty).Trim();
|
||||
|
||||
if (slug.Length is 0 or > MaxSlugLength)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
$"A slug of 1 to {MaxSlugLength} characters is required.");
|
||||
}
|
||||
|
||||
var previousWasHyphen = false;
|
||||
|
||||
for (var index = 0; index < slug.Length; index++)
|
||||
{
|
||||
var character = slug[index];
|
||||
var isHyphen = character == '-';
|
||||
|
||||
var acceptable = (character is >= 'a' and <= 'z')
|
||||
|| (character is >= '0' and <= '9')
|
||||
|| isHyphen;
|
||||
|
||||
if (!acceptable
|
||||
|| (isHyphen && (previousWasHyphen || index == 0 || index == slug.Length - 1)))
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"A slug is lowercase letters, digits and single hyphens, and cannot start or end "
|
||||
+ "with a hyphen.");
|
||||
}
|
||||
|
||||
previousWasHyphen = isHyphen;
|
||||
}
|
||||
|
||||
return slug;
|
||||
}
|
||||
|
||||
private static string RequireText(string? value, string field, int maxLength)
|
||||
{
|
||||
var text = (value ?? string.Empty).Trim();
|
||||
|
||||
if (text.Length == 0 || text.Length > maxLength)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{field} is required, and at most {maxLength} characters."));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
private static string? OptionalText(string? value, int maxLength)
|
||||
{
|
||||
var text = value?.Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (text.Length > maxLength)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"A description is at most {maxLength} characters."));
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A plain cast, which is why <c>TeamMemberRole</c> pins the same numeric values as
|
||||
/// <see cref="TeamRole"/> and a test asserts it. An unknown value becomes
|
||||
/// <see cref="TeamRole.Unspecified"/> rather than a silent cast to a role nobody defined, so a
|
||||
/// newer client's role is refused instead of resolving to whatever bit pattern it happens to be.
|
||||
/// </remarks>
|
||||
private static TeamRole ToDomain(TeamMemberRole role) => role switch
|
||||
{
|
||||
TeamMemberRole.Viewer => TeamRole.Viewer,
|
||||
TeamMemberRole.Member => TeamRole.Member,
|
||||
TeamMemberRole.Admin => TeamRole.Admin,
|
||||
TeamMemberRole.Owner => TeamRole.Owner,
|
||||
_ => TeamRole.Unspecified,
|
||||
};
|
||||
|
||||
private static TeamMemberRole ToContract(TeamRole role) => role switch
|
||||
{
|
||||
TeamRole.Viewer => TeamMemberRole.Viewer,
|
||||
TeamRole.Member => TeamMemberRole.Member,
|
||||
TeamRole.Admin => TeamMemberRole.Admin,
|
||||
TeamRole.Owner => TeamMemberRole.Owner,
|
||||
_ => TeamMemberRole.Unspecified,
|
||||
};
|
||||
|
||||
private static TeamMemberStatus ToContract(MembershipStatus status) => status switch
|
||||
{
|
||||
MembershipStatus.Invited => TeamMemberStatus.Invited,
|
||||
MembershipStatus.Active => TeamMemberStatus.Active,
|
||||
MembershipStatus.Revoked => TeamMemberStatus.Revoked,
|
||||
_ => TeamMemberStatus.Unspecified,
|
||||
};
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception) =>
|
||||
string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
using FastEndpoints;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>Lists who can open a vault.</summary>
|
||||
/// <remarks>
|
||||
/// Read, not Share. Every member who can read a vault can already see the sharing graph — the server
|
||||
/// stores it in plaintext and says so in docs/crypto.md §10 — so gating this on Share would hide from
|
||||
/// the people it is about something the operator can read either way.
|
||||
/// </remarks>
|
||||
internal sealed class ListVaultGrantsEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
VaultGrantService grants)
|
||||
: EndpointWithoutRequest<Results<Ok<VaultGrantsResponse>, NotFound>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Get("/api/v1/vaults/{vaultId:guid}/grants");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("ListVaultGrants")
|
||||
.WithSummary("Lists who holds a key to this vault.")
|
||||
.WithTags("Vaults"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<Ok<VaultGrantsResponse>, NotFound>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var access = await vaultAccess
|
||||
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
return TypedResults.Ok(await grants.ListGrantsAsync(access.Vault!, ct).ConfigureAwait(false));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Wraps this vault's key to another member.</summary>
|
||||
/// <remarks>
|
||||
/// The one call in this API whose body the server can neither produce nor check. It stores a sealed
|
||||
/// key and a signature over a tuple it never verifies — see docs/crypto.md §6 and §7 — which is
|
||||
/// exactly why sharing is a client operation with a server-side record rather than a server feature.
|
||||
/// </remarks>
|
||||
internal sealed class IssueVaultGrantEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
VaultGrantService grants)
|
||||
: Endpoint<IssueVaultGrantRequest, Results<NoContent, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Post("/api/v1/vaults/{vaultId:guid}/grants");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("IssueVaultGrant")
|
||||
.WithSummary("Records a vault key wrapped to another member.")
|
||||
.WithTags("Vaults"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
IssueVaultGrantRequest req,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var access = await vaultAccess
|
||||
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.Permissions.HasFlag(PermissionFlags.Share))
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"You do not have permission to share this vault.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await grants.IssueGrantAsync(user, access.Vault!, req, ct).ConfigureAwait(false);
|
||||
|
||||
// 204. There is nothing to return that the caller does not already hold — it produced
|
||||
// the wrap — and echoing the sealed key back would put it on the wire twice for nothing.
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
catch (VaultGrantInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Withdraws a member's key to this vault.</summary>
|
||||
/// <remarks>
|
||||
/// 404 for a member who holds no live grant, rather than a bland 204, for the reason device
|
||||
/// revocation gives: "revoked" is what the user reads, and reading it about the wrong account is
|
||||
/// worse than being told to look again. A caller driving towards "they cannot read this any more"
|
||||
/// can treat 404 as having arrived.
|
||||
/// </remarks>
|
||||
internal sealed class RevokeVaultGrantEndpoint(
|
||||
ICurrentUserContext currentUser,
|
||||
IVaultAccessService vaultAccess,
|
||||
VaultGrantService grants)
|
||||
: EndpointWithoutRequest<Results<NoContent, NotFound, ProblemHttpResult>>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override void Configure()
|
||||
{
|
||||
Delete("/api/v1/vaults/{vaultId:guid}/grants/{userId:guid}");
|
||||
|
||||
Policies(Auth.EnrolledPolicy);
|
||||
|
||||
Description(b => b
|
||||
.WithName("RevokeVaultGrant")
|
||||
.WithSummary("Withdraws a member's key to this vault. Blocks future reads only.")
|
||||
.WithTags("Vaults"));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async Task<Results<NoContent, NotFound, ProblemHttpResult>> ExecuteAsync(
|
||||
CancellationToken ct)
|
||||
{
|
||||
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
|
||||
var access = await vaultAccess
|
||||
.ResolveAsync(user.Id, Route<Guid>("vaultId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Read))
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
if (!access.Permissions.HasFlag(PermissionFlags.Share))
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status403Forbidden,
|
||||
ProblemCodes.Forbidden,
|
||||
"You do not have permission to share this vault.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var revoked = await grants
|
||||
.RevokeGrantAsync(user, access.Vault!, Route<Guid>("userId"), ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return revoked ? TypedResults.NoContent() : TypedResults.NotFound();
|
||||
}
|
||||
catch (VaultGrantInvalidException exception)
|
||||
{
|
||||
return Problems.Coded(
|
||||
StatusCodes.Status400BadRequest, ProblemCodes.InvalidVaultGrant, exception.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Team vaults and the key grants that make them readable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Everything here stores bytes it cannot interpret. A wrapped vault key is sealed to a recipient's
|
||||
/// X25519 key, and a grant signature is Ed25519 over a tuple this server never verifies — the two
|
||||
/// together are what let a client detect a fabricated grant, and moving either check onto the server
|
||||
/// would make it a convenience rather than the boundary. See docs/crypto.md §6 and §7.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What the server <em>can</em> check is that a grant is not obviously useless: that the recipient is
|
||||
/// enrolled, that the fingerprint names their current key, and that the generation is the vault's
|
||||
/// current one. Each of those would otherwise surface at the far end as a tag failure the recipient
|
||||
/// reads as data corruption, days later, with nothing pointing at the grant that caused it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VaultGrantService(
|
||||
DodoDbContext database,
|
||||
TimeProvider clock,
|
||||
ILogger<VaultGrantService> logger)
|
||||
{
|
||||
/// <summary>
|
||||
/// Largest wrapped vault key accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>SealTo</c> envelope over a 32-byte key is 6 + 32 + 24 + 48 = 110 bytes. The cap is loose
|
||||
/// enough to survive a future envelope — the reserved hybrid seal in docs/crypto.md §8 is far
|
||||
/// larger — and tight enough that this column cannot be used as free storage on a server that
|
||||
/// stores it without being able to read it.
|
||||
/// </remarks>
|
||||
private const int MaxWrappedKeyBytes = 4096;
|
||||
|
||||
/// <summary>Creates a vault owned by a team, with the creator's own grant.</summary>
|
||||
/// <remarks>
|
||||
/// The vault and its first grant are written together, for the reason enrollment gives about a
|
||||
/// personal vault: a vault with no grant is a container nobody can ever open, including whoever
|
||||
/// created it, because only a client can wrap the key and it has already moved on.
|
||||
/// </remarks>
|
||||
internal async Task<VaultSummary> CreateTeamVaultAsync(
|
||||
UserAccount user,
|
||||
Team team,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var name = RequireVaultName(request.Name);
|
||||
|
||||
if (request.VaultId == Guid.Empty)
|
||||
{
|
||||
throw new TeamInvalidException("A vault id is required. Generate a UUIDv7 on the client.");
|
||||
}
|
||||
|
||||
RequireWrappedKey(request.WrappedVaultKey);
|
||||
RequireSignature(request.GrantSignature);
|
||||
|
||||
var key = await RequireCurrentKeyAsync(user.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var taken = await database.Vaults
|
||||
.AnyAsync(v => v.Id == request.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (taken)
|
||||
{
|
||||
throw new TeamInvalidException(
|
||||
"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.");
|
||||
}
|
||||
|
||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||
|
||||
return new VaultSummary(
|
||||
VaultId: request.VaultId,
|
||||
Name: name,
|
||||
IsPersonal: false,
|
||||
TeamId: team.Id,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 0,
|
||||
WrappedVaultKey: request.WrappedVaultKey,
|
||||
RekeyRequired: false);
|
||||
}
|
||||
|
||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||
private void AddVaultWithSelfGrant(
|
||||
UserAccount user,
|
||||
Team team,
|
||||
CreateTeamVaultRequest request,
|
||||
string name,
|
||||
UserKey key,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
database.Vaults.Add(new Vault
|
||||
{
|
||||
Id = request.VaultId,
|
||||
Name = name,
|
||||
OwnerKind = VaultOwnerKind.Team,
|
||||
TeamId = team.Id,
|
||||
KeyGeneration = 1,
|
||||
CreatedAtUtc = now,
|
||||
UpdatedAtUtc = now,
|
||||
});
|
||||
|
||||
database.VaultKeyGrants.Add(new VaultKeyGrant
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = request.VaultId,
|
||||
KeyGeneration = 1,
|
||||
Kind = GrantKind.Member,
|
||||
RecipientUserId = user.Id,
|
||||
RecipientKeyFingerprint = key.FingerprintSha256,
|
||||
WrappedKey = request.WrappedVaultKey,
|
||||
GranterUserId = user.Id,
|
||||
GranterKeyFingerprint = key.FingerprintSha256,
|
||||
|
||||
// No key log head, exactly as a personal vault's self-grant carries none: there is no
|
||||
// third party whose key could have been substituted here.
|
||||
KeyLogHead = null,
|
||||
Signature = request.GrantSignature,
|
||||
State = GrantState.Active,
|
||||
CreatedAtUtc = now,
|
||||
});
|
||||
}
|
||||
|
||||
private static string RequireVaultName(string? value)
|
||||
{
|
||||
var name = (value ?? string.Empty).Trim();
|
||||
|
||||
return name.Length is 0 or > 256
|
||||
? throw new TeamInvalidException("A vault name of 1 to 256 characters is required.")
|
||||
: name;
|
||||
}
|
||||
|
||||
/// <summary>Lists who can open a vault.</summary>
|
||||
internal async Task<VaultGrantsResponse> ListGrantsAsync(
|
||||
Vault vault,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var grants = await database.VaultKeyGrants
|
||||
.Where(g => g.VaultId == vault.Id && g.Kind == GrantKind.Member)
|
||||
.Include(g => g.RecipientUser)
|
||||
.OrderBy(g => g.CreatedAtUtc)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new VaultGrantsResponse(
|
||||
VaultId: vault.Id,
|
||||
KeyGeneration: (uint)vault.KeyGeneration,
|
||||
RekeyRequired: vault.RekeyRequired,
|
||||
Grants:
|
||||
[
|
||||
.. grants.Select(g => new VaultGrantSummary(
|
||||
g.RecipientUserId!.Value,
|
||||
g.RecipientUser?.Email,
|
||||
g.RecipientUser?.DisplayName,
|
||||
(uint)g.KeyGeneration,
|
||||
ToContract(g.State),
|
||||
g.GranterUserId,
|
||||
g.CreatedAtUtc,
|
||||
g.RevokedAtUtc)),
|
||||
]);
|
||||
}
|
||||
|
||||
/// <summary>Wraps a vault key to another member.</summary>
|
||||
/// <remarks>
|
||||
/// Re-issuing to a recipient who already holds a live grant replaces it in place rather than
|
||||
/// inserting a second row, because the unique index permits exactly one live grant per recipient
|
||||
/// per generation — and because the operation somebody is actually performing when they do this
|
||||
/// is "wrap it again", after a rotation or a botched first attempt.
|
||||
/// </remarks>
|
||||
internal async Task IssueGrantAsync(
|
||||
UserAccount actor,
|
||||
Vault vault,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await RequireIssuableAsync(vault, request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var granterKey = await RequireCurrentKeyAsync(actor.Id, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var existing = await database.VaultKeyGrants
|
||||
.SingleOrDefaultAsync(
|
||||
g => g.VaultId == vault.Id
|
||||
&& g.KeyGeneration == vault.KeyGeneration
|
||||
&& g.RecipientUserId == request.RecipientUserId
|
||||
&& g.RevokedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var grant = existing ?? new VaultKeyGrant
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
VaultId = vault.Id,
|
||||
KeyGeneration = vault.KeyGeneration,
|
||||
Kind = GrantKind.Member,
|
||||
RecipientUserId = request.RecipientUserId,
|
||||
CreatedAtUtc = clock.GetUtcNow(),
|
||||
};
|
||||
|
||||
grant.RecipientKeyFingerprint = request.RecipientKeyFingerprint;
|
||||
grant.WrappedKey = request.WrappedVaultKey;
|
||||
grant.GranterUserId = actor.Id;
|
||||
|
||||
// Taken from the server's own view of the caller's key rather than from the request. The
|
||||
// client signed over the same value, so an honest client is unaffected; a field that could
|
||||
// disagree with reality is one a reader would have to decide which copy to believe.
|
||||
grant.GranterKeyFingerprint = granterKey.FingerprintSha256;
|
||||
|
||||
grant.KeyLogHead = request.KeyLogHead;
|
||||
grant.Signature = request.GrantSignature;
|
||||
grant.State = GrantState.Active;
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
database.VaultKeyGrants.Add(grant);
|
||||
}
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.GrantIssued(
|
||||
logger, vault.Id, vault.KeyGeneration, request.RecipientUserId, actor.Id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything that can be checked about a grant without holding the vault key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// None of this verifies that the wrap contains the right key — nothing on this machine can. Each
|
||||
/// check exists because failing it would otherwise surface at the recipient as a tag failure they
|
||||
/// read as data corruption, long after the request that caused it.
|
||||
/// </remarks>
|
||||
private async Task RequireIssuableAsync(
|
||||
Vault vault,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Team vaults only. A personal vault has exactly one subject who may reach it, so a grant on
|
||||
// one would seal a key to somebody the access check will go on refusing — a row that looks
|
||||
// like sharing and is not. Moving the items into a team vault is the operation that shares.
|
||||
if (vault.OwnerKind != VaultOwnerKind.Team || vault.TeamId is not { } teamId)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"Only a team vault can be shared. A personal vault is reachable by its owner alone, "
|
||||
+ "so a grant on one would seal a key to somebody who still could not fetch it.");
|
||||
}
|
||||
|
||||
RequireWrappedKey(request.WrappedVaultKey);
|
||||
RequireSignature(request.GrantSignature);
|
||||
RequireDigest(request.RecipientKeyFingerprint, "recipient key fingerprint");
|
||||
RequireDigest(request.KeyLogHead, "key log head");
|
||||
|
||||
if (request.KeyGeneration != (uint)vault.KeyGeneration)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
$"This vault is at key generation {vault.KeyGeneration}. A grant for generation "
|
||||
+ $"{request.KeyGeneration} would open nothing.");
|
||||
}
|
||||
|
||||
var member = await database.TeamMemberships
|
||||
.AnyAsync(
|
||||
m => m.TeamId == teamId
|
||||
&& m.UserId == request.RecipientUserId
|
||||
&& m.Status == MembershipStatus.Active
|
||||
&& m.DeletedAtUtc == null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!member)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"That account is not an active member of the team that owns this vault. Add them to "
|
||||
+ "the team first — a key wrapped to somebody the server will refuse to serve is a "
|
||||
+ "grant that does nothing.");
|
||||
}
|
||||
|
||||
var recipientKey = await RequireCurrentKeyAsync(request.RecipientUserId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// The fingerprint the client signed over must be the key the recipient actually holds.
|
||||
// Otherwise the grant is sealed to a superseded key, opens nothing, and surfaces at the far
|
||||
// end as an unexplained decryption failure rather than as the mistake it is.
|
||||
if (!CryptographicOperations.FixedTimeEquals(
|
||||
recipientKey.FingerprintSha256, request.RecipientKeyFingerprint))
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"The fingerprint does not name the recipient's current identity key. Re-read the "
|
||||
+ "directory and wrap the key again — theirs has been rotated since you fetched it.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a member's key grant.
|
||||
/// </summary>
|
||||
/// <returns>Whether there was a live grant to withdraw.</returns>
|
||||
/// <remarks>
|
||||
/// Blocks future reads and nothing else. Anything the recipient has already pulled is on their
|
||||
/// machine and stays there, which is why the vault is flagged for rekey and why the honest
|
||||
/// remediation for a departure is rotating the SSH credential itself. See ADR 0001.
|
||||
/// </remarks>
|
||||
internal async Task<bool> RevokeGrantAsync(
|
||||
UserAccount actor,
|
||||
Vault vault,
|
||||
Guid recipientUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (recipientUserId == actor.Id)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
"You cannot withdraw your own key. It would leave you unable to read a vault you can "
|
||||
+ "still write to, and nothing here can hand it back.");
|
||||
}
|
||||
|
||||
var grants = await database.VaultKeyGrants
|
||||
.Where(g => g.VaultId == vault.Id
|
||||
&& g.RecipientUserId == recipientUserId
|
||||
&& g.RevokedAtUtc == null)
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (grants.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
foreach (var grant in grants)
|
||||
{
|
||||
grant.State = GrantState.Revoked;
|
||||
grant.RevokedAtUtc = now;
|
||||
}
|
||||
|
||||
vault.RekeyRequired = true;
|
||||
vault.RekeyReason = RekeyReason.Requested;
|
||||
vault.UpdatedAtUtc = now;
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
TeamLog.GrantRevoked(logger, vault.Id, recipientUserId, actor.Id);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<UserKey> RequireCurrentKeyAsync(Guid userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = await database.UserKeys
|
||||
.SingleOrDefaultAsync(k => k.UserId == userId && k.IsCurrent, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return key
|
||||
?? throw new VaultGrantInvalidException(
|
||||
"That account has not published an identity key yet, so there is nothing to wrap a "
|
||||
+ "vault key to. They have to sign in and set up their vault first.");
|
||||
}
|
||||
|
||||
private static void RequireWrappedKey(byte[]? value)
|
||||
{
|
||||
if (value is null || value.Length == 0 || value.Length > MaxWrappedKeyBytes)
|
||||
{
|
||||
throw new VaultGrantInvalidException(
|
||||
$"A wrapped vault key of 1 to {MaxWrappedKeyBytes} bytes is required.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireSignature(byte[]? value)
|
||||
{
|
||||
if (value is null || value.Length != 64)
|
||||
{
|
||||
throw new VaultGrantInvalidException("An Ed25519 grant signature is 64 bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RequireDigest(byte[]? value, string field)
|
||||
{
|
||||
if (value is null || value.Length != 32)
|
||||
{
|
||||
throw new VaultGrantInvalidException($"A {field} is 32 bytes.");
|
||||
}
|
||||
}
|
||||
|
||||
private static VaultGrantState ToContract(GrantState state) => state switch
|
||||
{
|
||||
GrantState.Active => VaultGrantState.Active,
|
||||
GrantState.AwaitingRewrap => VaultGrantState.AwaitingRewrap,
|
||||
GrantState.Revoked => VaultGrantState.Revoked,
|
||||
_ => VaultGrantState.Unspecified,
|
||||
};
|
||||
|
||||
private static bool IsUniqueViolation(DbUpdateException exception) =>
|
||||
string.Equals(
|
||||
(exception.InnerException as PostgresException)?.SqlState,
|
||||
PostgresErrorCodes.UniqueViolation,
|
||||
StringComparison.Ordinal);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Api.Features.Identity;
|
||||
using DodoSSH.Api.Features.Sync;
|
||||
using DodoSSH.Api.Features.Teams;
|
||||
using DodoSSH.Api.Setup;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Authorization.Policy;
|
||||
@@ -32,6 +33,10 @@ builder.Services.AddScoped<SyncService>();
|
||||
builder.Services.AddScoped<IdentityService>();
|
||||
builder.Services.AddScoped<EnrollmentService>();
|
||||
builder.Services.AddScoped<DeviceService>();
|
||||
builder.Services.AddScoped<DirectoryService>();
|
||||
builder.Services.AddScoped<KeyLogService>();
|
||||
builder.Services.AddScoped<TeamService>();
|
||||
builder.Services.AddScoped<VaultGrantService>();
|
||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using DodoSSH.Api.Features.Identity;
|
||||
using DodoSSH.Api.Features.Meta;
|
||||
using DodoSSH.Api.Features.Sync;
|
||||
using DodoSSH.Api.Features.Teams;
|
||||
using DodoSSH.Contracts;
|
||||
using FastEndpoints;
|
||||
|
||||
@@ -38,15 +39,26 @@ internal static class EndpointRegistration
|
||||
typeof(EnrollEndpoint),
|
||||
typeof(RegisterDeviceEndpoint),
|
||||
typeof(RevokeDeviceEndpoint),
|
||||
typeof(LookupDirectoryEndpoint),
|
||||
typeof(ReadKeyLogEndpoint),
|
||||
typeof(SyncPullEndpoint),
|
||||
typeof(SyncPushEndpoint),
|
||||
typeof(CreateTeamEndpoint),
|
||||
typeof(ListTeamsEndpoint),
|
||||
typeof(ListTeamMembersEndpoint),
|
||||
typeof(AddTeamMemberEndpoint),
|
||||
typeof(ChangeTeamMemberRoleEndpoint),
|
||||
typeof(RemoveTeamMemberEndpoint),
|
||||
typeof(CreateTeamVaultEndpoint),
|
||||
typeof(ListVaultGrantsEndpoint),
|
||||
typeof(IssueVaultGrantEndpoint),
|
||||
typeof(RevokeVaultGrantEndpoint),
|
||||
|
||||
// Registered as each feature lands:
|
||||
// Identity — key rotation, passphrase change
|
||||
// Directory — public-key lookup
|
||||
// Vaults — grants, rekey, ACL
|
||||
// Vaults — rekey, per-item ACLs
|
||||
// Relay — tickets and the WebSocket
|
||||
// Teams, Audit, Admin
|
||||
// Audit, Admin
|
||||
});
|
||||
|
||||
/// <summary>Hides the endpoint listing FastEndpoints publishes at <c>GET /_test_url_cache_</c>.</summary>
|
||||
|
||||
@@ -934,6 +934,21 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.import": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.objectstore": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, )",
|
||||
"AWSSDK.S3": "[4.0.101.6, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -942,7 +957,8 @@
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.shell": {
|
||||
@@ -950,6 +966,8 @@
|
||||
"dependencies": {
|
||||
"Avalonia": "[12.1.1, )",
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Import": "[1.0.0, )",
|
||||
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
@@ -959,6 +977,7 @@
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -1002,6 +1021,21 @@
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
|
||||
@@ -58,6 +58,115 @@ public interface IAccountApi
|
||||
Task<bool> RevokeDeviceAsync(Guid deviceId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Teams, their members, and the vaults they own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separated from <see cref="IVaultGrantApi"/> although the two are used together, because they are
|
||||
/// different kinds of act. Everything here changes what the <em>server</em> will serve and can be
|
||||
/// performed by anything holding a token. Issuing a grant needs a vault key, which only an unlocked
|
||||
/// session has — so the two live behind different interfaces and are tested against different fakes.
|
||||
/// </remarks>
|
||||
public interface ITeamApi
|
||||
{
|
||||
/// <summary>Lists the teams the caller belongs to.</summary>
|
||||
Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates a team, with the caller as its owner.</summary>
|
||||
Task<TeamSummary> CreateTeamAsync(CreateTeamRequest request, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Lists a team's members.</summary>
|
||||
Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Adds a member to a team.</summary>
|
||||
Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Changes a member's role.</summary>
|
||||
Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Removes a member, revoking every vault key grant they hold from this team.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Whether the team had that member. False means it did not, which a caller driving towards
|
||||
/// "they are not in this team" should treat as having arrived.
|
||||
/// </returns>
|
||||
Task<bool> RemoveTeamMemberAsync(Guid teamId, Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates a vault owned by a team, with the creator's key grant.</summary>
|
||||
Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The public-key directory and the log that makes it checkable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two belong together and are used together: a directory answer is a claim, and the key log is
|
||||
/// what turns it into something a client can verify. Splitting them would make it possible to build a
|
||||
/// caller that reads one and not the other, which is precisely the mistake — see ADR 0001 — that
|
||||
/// undoes end-to-end encryption entirely.
|
||||
/// </remarks>
|
||||
public interface IDirectoryApi
|
||||
{
|
||||
/// <summary>Looks a user up by exact email address. There is no search.</summary>
|
||||
Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Looks up an account the caller shares a team with.</summary>
|
||||
Task<DirectoryEntry?> LookupByIdAsync(Guid userId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Reads entries after a sequence, with the log's current head.</summary>
|
||||
Task<KeyLogPage> ReadKeyLogAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Vault key grants: who can open a vault, and the record of who let them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The wrapped key and the signature are produced by an unlocked session and are opaque to everything
|
||||
/// between it and the recipient, this interface included.
|
||||
/// </remarks>
|
||||
public interface IVaultGrantApi
|
||||
{
|
||||
/// <summary>Lists who holds a key to this vault.</summary>
|
||||
Task<VaultGrantsResponse> ListVaultGrantsAsync(Guid vaultId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Records a vault key wrapped to another member.</summary>
|
||||
Task IssueVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws a member's key to this vault.
|
||||
/// </summary>
|
||||
/// <returns>Whether there was a live grant to withdraw.</returns>
|
||||
/// <remarks>
|
||||
/// Blocks future reads and nothing else. Whatever they have already pulled is on their machine;
|
||||
/// the remediation for a departure is rotating the SSH credential. See ADR 0001.
|
||||
/// </remarks>
|
||||
Task<bool> RevokeVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The two vault-synchronisation calls, separated so the sync engine can be driven without HTTP.
|
||||
/// </summary>
|
||||
@@ -99,13 +208,16 @@ public interface ISyncApi
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
|
||||
: IAccountApi, ISyncApi
|
||||
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi
|
||||
{
|
||||
private const string MetaPath = "/api/v1/meta";
|
||||
private const string ConfigurationPath = "/.well-known/dodossh-configuration";
|
||||
private const string MePath = "/api/v1/me";
|
||||
private const string EnrollmentPath = "/api/v1/me/enrollment";
|
||||
private const string DevicesPath = "/api/v1/me/devices";
|
||||
private const string DirectoryPath = "/api/v1/directory";
|
||||
private const string KeyLogPath = "/api/v1/keylog";
|
||||
private const string TeamsPath = "/api/v1/teams";
|
||||
|
||||
/// <summary>
|
||||
/// Reads the server's capabilities, versions and limits.
|
||||
@@ -209,6 +321,168 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
DodoSshJsonContext.Default.SyncPushResponse,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamSummary>> ListTeamsAsync(CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Get,
|
||||
TeamsPath,
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListTeamSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamSummary> CreateTeamAsync(
|
||||
CreateTeamRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
TeamsPath,
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamRequest),
|
||||
DodoSshJsonContext.Default.TeamSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||
Guid teamId,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Get,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListTeamMemberSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||
Guid teamId,
|
||||
AddTeamMemberRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.AddTeamMemberRequest),
|
||||
DodoSshJsonContext.Default.TeamMemberSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TeamMemberSummary> ChangeTeamMemberRoleAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
ChangeTeamMemberRoleRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Put,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}/role"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.ChangeTeamMemberRoleRequest),
|
||||
DodoSshJsonContext.Default.TeamMemberSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RemoveTeamMemberAsync(
|
||||
Guid teamId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
DeleteAsync(
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/members/{userId}"),
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultSummary> CreateTeamVaultAsync(
|
||||
Guid teamId,
|
||||
CreateTeamVaultRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Post,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{TeamsPath}/{teamId}/vaults"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.CreateTeamVaultRequest),
|
||||
DodoSshJsonContext.Default.VaultSummary,
|
||||
cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Looks a user up by exact email address.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The address is escaped into the query string, which is the one place in this client where a
|
||||
/// value a user typed reaches a URL. <see cref="Uri.EscapeDataString"/> rather than string
|
||||
/// concatenation: an unescaped <c>&</c> or <c>#</c> in an address would silently become a
|
||||
/// lookup for something else.
|
||||
/// </remarks>
|
||||
public Task<IReadOnlyList<DirectoryEntry>> LookupByEmailAsync(
|
||||
string email,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(email);
|
||||
|
||||
return SendAsync(
|
||||
HttpMethod.Get,
|
||||
$"{DirectoryPath}?email={Uri.EscapeDataString(email)}",
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DirectoryEntry?> LookupByIdAsync(
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await SendAsync(
|
||||
HttpMethod.Get,
|
||||
string.Create(CultureInfo.InvariantCulture, $"{DirectoryPath}?userId={userId}"),
|
||||
null,
|
||||
DodoSshJsonContext.Default.IReadOnlyListDirectoryEntry,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return entries.Count == 0 ? null : entries[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<KeyLogPage> ReadKeyLogAsync(
|
||||
long afterSequence,
|
||||
int? limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var path = limit is null
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}")
|
||||
: string.Create(
|
||||
CultureInfo.InvariantCulture, $"{KeyLogPath}?after={afterSequence}&limit={limit}");
|
||||
|
||||
return SendAsync(
|
||||
HttpMethod.Get, path, null, DodoSshJsonContext.Default.KeyLogPage, cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<VaultGrantsResponse> ListVaultGrantsAsync(
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendAsync(
|
||||
HttpMethod.Get,
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
|
||||
null,
|
||||
DodoSshJsonContext.Default.VaultGrantsResponse,
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task IssueVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
IssueVaultGrantRequest request,
|
||||
CancellationToken cancellationToken) =>
|
||||
SendNoContentAsync(
|
||||
HttpMethod.Post,
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants"),
|
||||
JsonContent.Create(request, DodoSshJsonContext.Default.IssueVaultGrantRequest),
|
||||
cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RevokeVaultGrantAsync(
|
||||
Guid vaultId,
|
||||
Guid userId,
|
||||
CancellationToken cancellationToken) =>
|
||||
DeleteAsync(
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/v1/vaults/{vaultId}/grants/{userId}"),
|
||||
cancellationToken);
|
||||
|
||||
private async Task<T> GetAnonymousAsync<T>(
|
||||
string path,
|
||||
System.Text.Json.Serialization.Metadata.JsonTypeInfo<T> typeInfo,
|
||||
@@ -242,6 +516,37 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
|
||||
/// disagree about what a missing body means. Everywhere else a 200 with nothing in it is a server bug
|
||||
/// worth an exception; here it is the answer.
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Sends a request whose success carries no body.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own path for the reason <see cref="DeleteAsync"/> gives, minus the 404: a grant that will
|
||||
/// not be recorded is a failure with a problem document behind it, so there is nothing here to
|
||||
/// translate into a return value.
|
||||
/// </remarks>
|
||||
private async Task SendNoContentAsync(
|
||||
HttpMethod method,
|
||||
string path,
|
||||
HttpContent? content,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, path) { Content = content };
|
||||
|
||||
var token = await tokens.GetAccessTokenAsync(cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
using var response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content
|
||||
.ReadAsStringAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
throw DodoSshApiException.FromResponse(response.StatusCode, body);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, path);
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Api;
|
||||
|
||||
/// <summary>Why a directory entry was or was not accepted.</summary>
|
||||
public enum RecipientVerdict
|
||||
{
|
||||
/// <summary>Not a legal value.</summary>
|
||||
Unspecified = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The key log verifies, and it introduces exactly the key the directory described.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the strongest statement a client can make without an out-of-band fingerprint check. It
|
||||
/// says the server has been consistent, not that the key is the right person's — see
|
||||
/// <see cref="VerifiedRecipient.Fingerprint"/> and ADR 0001.
|
||||
/// </remarks>
|
||||
Verified = 1,
|
||||
|
||||
/// <summary>No account with that address, or none the caller may look up.</summary>
|
||||
NotFound = 2,
|
||||
|
||||
/// <summary>The account exists but has published no identity key, so there is nothing to wrap to.</summary>
|
||||
NotEnrolled = 3,
|
||||
|
||||
/// <summary>
|
||||
/// The key log's hash chain does not verify.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Either the log has been edited or this build and the server disagree about how an entry is
|
||||
/// hashed. Both are refusals: wrapping a vault key against a log that cannot be checked is the
|
||||
/// same as not checking one.
|
||||
/// </remarks>
|
||||
ChainBroken = 4,
|
||||
|
||||
/// <summary>
|
||||
/// The log holds no entry matching the key the directory returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The exact case key transparency exists for. A server that wants to substitute a key it holds
|
||||
/// has to publish it in the append-only log to get past this, where every other client will see
|
||||
/// it.
|
||||
/// </remarks>
|
||||
NotInKeyLog = 5,
|
||||
|
||||
/// <summary>The fingerprint does not match the keys it is supposed to be over.</summary>
|
||||
FingerprintMismatch = 6,
|
||||
|
||||
/// <summary>
|
||||
/// The log introduces a newer generation for this user than the directory returned.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A rotation the directory has not caught up with, or a stale answer being served on purpose.
|
||||
/// Refused either way: a key wrapped to a superseded generation opens nothing, and the recipient
|
||||
/// reads that as corruption rather than as a race.
|
||||
/// </remarks>
|
||||
Superseded = 7,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A recipient whose published key has been checked against the key log.
|
||||
/// </summary>
|
||||
/// <param name="Entry">The directory entry, as returned.</param>
|
||||
/// <param name="KeyLogHead">
|
||||
/// The log head observed while verifying, to be recorded in the grant. This is what makes a forked
|
||||
/// view detectable: two clients handed different logs sign over different heads, and the mismatch
|
||||
/// surfaces the next time either touches a vault the other can see.
|
||||
/// </param>
|
||||
/// <param name="Fingerprint">
|
||||
/// The recipient's identity fingerprint, recomputed here rather than taken from the response.
|
||||
/// <para>
|
||||
/// <b>Show this to a human before sharing anything that matters.</b> Everything above proves the
|
||||
/// server has been internally consistent; only somebody comparing this value with the recipient over
|
||||
/// a channel the server does not control can prove it is the right person's key.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record VerifiedRecipient(
|
||||
DirectoryEntry Entry,
|
||||
byte[] KeyLogHead,
|
||||
byte[] Fingerprint);
|
||||
|
||||
/// <summary>The outcome of verifying a recipient.</summary>
|
||||
/// <param name="Verdict">What happened.</param>
|
||||
/// <param name="Recipient">The recipient, present only when verified.</param>
|
||||
/// <param name="Message">One line for a person. Never contains key material.</param>
|
||||
public sealed record RecipientVerification(
|
||||
RecipientVerdict Verdict,
|
||||
VerifiedRecipient? Recipient,
|
||||
string Message)
|
||||
{
|
||||
/// <summary>Whether a key came back that is safe to wrap to.</summary>
|
||||
public bool IsVerified => Verdict == RecipientVerdict.Verified && Recipient is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the whole key log, checks its hash chain, and decides whether a directory answer agrees
|
||||
/// with it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is the check that makes sharing safe to offer at all.</b> A directory lookup is a claim by
|
||||
/// the server about somebody else's public key; wrapping a vault key to an unverified claim hands the
|
||||
/// vault to whoever made it, and no amount of transport security helps, because the server is inside
|
||||
/// the threat model. See ADR 0001 and docs/crypto.md §7.2.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The whole log is read from the beginning, every time, rather than from a cached cursor. It is
|
||||
/// small — one entry per identity key ever published, so a few hundred rows for a large deployment —
|
||||
/// and a client that verified only the tail would accept a chain whose earlier links it had never
|
||||
/// seen. Caching a verified prefix is a worthwhile optimisation and is deliberately not done yet:
|
||||
/// it needs somewhere to keep the prefix that the server cannot influence, and the client's
|
||||
/// preferences store does not exist.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this cannot do is tell you the key belongs to the person you mean. A server that publishes a
|
||||
/// substituted key in the log passes every check here — it is now on the record, which is the whole
|
||||
/// mechanism: detectable, attributable, not prevented. The fingerprint comes back for a human to
|
||||
/// compare out of band, which is the only step that closes it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class KeyLogAudit
|
||||
{
|
||||
/// <summary>Entries requested per page.</summary>
|
||||
private const int PageSize = 500;
|
||||
|
||||
/// <summary>
|
||||
/// Pages the whole log with the chain checked link by link.
|
||||
/// </summary>
|
||||
/// <returns>The verified log, or a null <c>Entries</c> when a link did not hold.</returns>
|
||||
public static async Task<AuditedKeyLog> ReadAsync(
|
||||
IDirectoryApi directory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(directory);
|
||||
|
||||
var entries = new List<KeyLogRecord>();
|
||||
var previous = KeyLogChain.CreateGenesisPreviousHash();
|
||||
var after = 0L;
|
||||
var head = previous;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var page = await directory.ReadKeyLogAsync(after, PageSize, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
foreach (var entry in page.Entries)
|
||||
{
|
||||
if (!Links(entry, previous))
|
||||
{
|
||||
return new AuditedKeyLog(null, head);
|
||||
}
|
||||
|
||||
entries.Add(entry);
|
||||
previous = entry.Hash;
|
||||
after = entry.Sequence;
|
||||
}
|
||||
|
||||
head = page.Head;
|
||||
|
||||
if (!page.HasMore)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// A page that advanced nothing would loop for ever. It means the server is answering a
|
||||
// cursor it will not move past, which is a broken log from this side of the wire.
|
||||
if (page.Entries.Count == 0)
|
||||
{
|
||||
return new AuditedKeyLog(null, head);
|
||||
}
|
||||
}
|
||||
|
||||
// The last link has to be the head the server claims, or the log served and the log
|
||||
// summarised are two different things.
|
||||
return entries.Count > 0 && !CryptographicOperations.FixedTimeEquals(previous, head)
|
||||
? new AuditedKeyLog(null, head)
|
||||
: new AuditedKeyLog(entries, head);
|
||||
}
|
||||
|
||||
/// <summary>Decides whether a directory entry agrees with a verified log.</summary>
|
||||
public static RecipientVerification Verify(AuditedKeyLog log, DirectoryEntry? entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(log);
|
||||
|
||||
if (log.Entries is null)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.ChainBroken,
|
||||
null,
|
||||
"The server's key log does not verify. Nothing will be shared with anyone until it "
|
||||
+ "does — an unverifiable log is the same as no log.");
|
||||
}
|
||||
|
||||
if (entry is null)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.NotFound,
|
||||
null,
|
||||
"No account here has that address. They have to sign in to this server once before "
|
||||
+ "anything can be shared with them.");
|
||||
}
|
||||
|
||||
var fingerprint = DshCrypto.ComputeFingerprint(
|
||||
entry.EncryptionPublicKey, entry.SigningPublicKey);
|
||||
|
||||
if (!CryptographicOperations.FixedTimeEquals(fingerprint, entry.Fingerprint))
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.FingerprintMismatch,
|
||||
null,
|
||||
"The fingerprint the directory returned is not the fingerprint of the keys it "
|
||||
+ "returned with it.");
|
||||
}
|
||||
|
||||
return Compare(log, entry, fingerprint);
|
||||
}
|
||||
|
||||
/// <summary>Compares one directory entry with the log entries for that account.</summary>
|
||||
private static RecipientVerification Compare(
|
||||
AuditedKeyLog log,
|
||||
DirectoryEntry entry,
|
||||
byte[] fingerprint)
|
||||
{
|
||||
var forUser = log.Entries!.Where(e => e.UserId == entry.UserId).ToList();
|
||||
|
||||
if (forUser.Count == 0)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.NotEnrolled,
|
||||
null,
|
||||
"That account has published no identity key, so there is nothing to wrap a vault key "
|
||||
+ "to.");
|
||||
}
|
||||
|
||||
var latest = forUser.Max(e => e.Generation);
|
||||
|
||||
if (latest > entry.KeyGeneration)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.Superseded,
|
||||
null,
|
||||
$"The key log has generation {latest} for that account and the directory offered "
|
||||
+ $"{entry.KeyGeneration}. Wrapping to a superseded key would open nothing.");
|
||||
}
|
||||
|
||||
var matching = forUser.Find(e =>
|
||||
e.Generation == entry.KeyGeneration
|
||||
&& e.EncryptionPublicKey.AsSpan().SequenceEqual(entry.EncryptionPublicKey)
|
||||
&& e.SigningPublicKey.AsSpan().SequenceEqual(entry.SigningPublicKey));
|
||||
|
||||
if (matching is null)
|
||||
{
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.NotInKeyLog,
|
||||
null,
|
||||
"The key the directory returned does not appear in the append-only key log. This is "
|
||||
+ "exactly the substitution the log exists to catch; do not share anything with this "
|
||||
+ "account until it is explained.");
|
||||
}
|
||||
|
||||
return new RecipientVerification(
|
||||
RecipientVerdict.Verified,
|
||||
new VerifiedRecipient(entry, log.Head, fingerprint),
|
||||
"Verified against the key log. Compare the fingerprint with them out of band before "
|
||||
+ "sharing anything that matters.");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Recomputed rather than compared: the point is that this client derives the hash from the
|
||||
/// entry's own contents, so a server that edited a field cannot hand over a hash that covers the
|
||||
/// original.
|
||||
/// </remarks>
|
||||
private static bool Links(KeyLogRecord entry, byte[] previous)
|
||||
{
|
||||
if (!CryptographicOperations.FixedTimeEquals(entry.PreviousHash, previous))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
byte[] computed;
|
||||
|
||||
try
|
||||
{
|
||||
computed = KeyLogChain.ComputeEntryHash(
|
||||
entry.PreviousHash,
|
||||
entry.UserId,
|
||||
entry.Generation,
|
||||
entry.EncryptionPublicKey,
|
||||
entry.SigningPublicKey,
|
||||
entry.StatementSignature,
|
||||
entry.CreatedAt);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// A key or signature of the wrong length. Malformed rather than merely mismatched, and a
|
||||
// refusal either way.
|
||||
return false;
|
||||
}
|
||||
|
||||
return CryptographicOperations.FixedTimeEquals(computed, entry.Hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A key log that has been read, with its chain checked.</summary>
|
||||
/// <param name="Entries">
|
||||
/// Every entry in order, or <see langword="null"/> when a link did not hold. Null is the only
|
||||
/// signal a caller needs: a partially verified log is not a weaker answer, it is no answer.
|
||||
/// </param>
|
||||
/// <param name="Head">The head the server reported, for recording in a grant.</param>
|
||||
public sealed record AuditedKeyLog(IReadOnlyList<KeyLogRecord>? Entries, byte[] Head);
|
||||
@@ -271,13 +271,32 @@
|
||||
runs horizontally and a left bar on a row of tabs reads as a divider between them.
|
||||
-->
|
||||
<Style Selector="Button.tab">
|
||||
<Setter Property="Padding" Value="12,0" />
|
||||
<!-- Less on the right than the left: the close box lives inside the tab and brings its own margin. -->
|
||||
<Setter Property="Padding" Value="12,0,7,0" />
|
||||
<Setter Property="VerticalAlignment" Value="Stretch" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="10.5" />
|
||||
<Setter Property="FontWeight" Value="Medium" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The button that opens a connection. A tab in every respect but the marks a tab carries: no active
|
||||
state, because it is never the thing showing, and no right border, because it is not separating
|
||||
itself from anything.
|
||||
-->
|
||||
<Style Selector="Button.tab.plus">
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.plus /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="BorderThickness" Value="0,2,0,0" />
|
||||
<Setter Property="BorderBrush" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab.plus:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource BorderSubtle}" />
|
||||
@@ -290,6 +309,34 @@
|
||||
<Setter Property="BorderThickness" Value="0,2,0,0" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
A pair of buttons standing in for a two-way choice, inside a pane rather than down a rail. Not the
|
||||
.cat style, which stretches to fill a 176-pixel rail row and would be wrong at this width — and which
|
||||
the category rail's own test counts, so borrowing it would have made this a fourth category.
|
||||
-->
|
||||
<Style Selector="Button.choice">
|
||||
<Setter Property="Padding" Value="10,5" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="9.5" />
|
||||
<Setter Property="LetterSpacing" Value="0.5" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<Style Selector="Button.choice /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Border}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||
</Style>
|
||||
<Style Selector="Button.choice:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="Button.choice.active /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentWash}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource Accent}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
|
||||
<!-- The close box on a tab, and the window controls. Square, quiet, and red only where it means it. -->
|
||||
<Style Selector="Button.close /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
@@ -299,6 +346,15 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
The one inside a tab, as opposed to the ones in the titlebar. Rounded and small, because a square
|
||||
full-height red panel inside a tab reads as a divider between two tabs rather than as part of one —
|
||||
which is what it looked like while it was a sibling of the tab instead of a child.
|
||||
-->
|
||||
<Style Selector="Button.close.inline /template/ ContentPresenter#PART_ContentPresenter">
|
||||
<Setter Property="CornerRadius" Value="3" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Text input. Fluent draws a filled box with a thick focus underline; this design draws a hairline field
|
||||
that changes border colour, and the two do not sit together in one row.
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DodoSSH.Client.Shell.Terminal;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.Terminal;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
@@ -47,6 +50,28 @@ internal sealed partial class DodoSshApp : Application
|
||||
/// nowhere honest to release them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Puts one line of text on the system clipboard.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The clipboard is reached through the window, and at composition time there is no window yet — hence
|
||||
/// a closure that looks it up on each call rather than a reference captured now. A machine with no
|
||||
/// clipboard falls through silently here; the view model is the one that decides what to say, and it
|
||||
/// distinguishes "no clipboard on this machine" from "copied" because they are different answers.
|
||||
/// <para>
|
||||
/// A delegate rather than handing the view model an <c>IClipboard</c>, so that nothing in the view
|
||||
/// models needs a visual and every test that drives them stays window-free.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static Func<string, Task> ClipboardWriter(IClassicDesktopStyleApplicationLifetime desktop) =>
|
||||
async text =>
|
||||
{
|
||||
if (TopLevel.GetTopLevel(desktop.MainWindow) is { Clipboard: { } clipboard })
|
||||
{
|
||||
await clipboard.SetTextAsync(text).ConfigureAwait(false);
|
||||
}
|
||||
};
|
||||
|
||||
private static void Compose(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
var paths = ClientPaths.Default;
|
||||
@@ -74,7 +99,7 @@ internal sealed partial class DodoSshApp : Application
|
||||
// Chosen once, here, because it is a property of the machine and not of any session. A computer with
|
||||
// a usable TPM gets the store that keeps a device key behind a Windows consent prompt; anything else
|
||||
// gets one that reports itself unavailable, so unlock keeps asking for the passphrase. See ADR 0007.
|
||||
var deviceKeys = DeviceKeyStores.ForThisMachine(paths);
|
||||
var deviceKeys = DesktopDeviceKeyStores.ForThisMachine(paths);
|
||||
|
||||
var viewModel = new MainWindowViewModel(
|
||||
paths,
|
||||
@@ -93,7 +118,9 @@ internal sealed partial class DodoSshApp : Application
|
||||
// makes a launch after the first one arrive online rather than merely enrolled.
|
||||
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
|
||||
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
.ConfigureAwait(false),
|
||||
|
||||
copyToClipboard: ClipboardWriter(desktop));
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
|
||||
@@ -30,8 +30,10 @@
|
||||
head. This project is now the desktop *views* and the desktop platform integration, and nothing else.
|
||||
-->
|
||||
<ProjectReference Include="../DodoSSH.Client.Shell/DodoSSH.Client.Shell.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
</ItemGroup>
|
||||
@@ -51,3 +53,4 @@
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
|
||||
+14
-3
@@ -1,17 +1,28 @@
|
||||
using System.Runtime.Versioning;
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Picks the device key store this machine can actually offer.
|
||||
/// Picks the device key store this desktop machine can actually offer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One place decides, so nothing above has to carry a platform guard. A machine with no TPM, or one that
|
||||
/// is not Windows, gets <see cref="UnavailableDeviceKeyStore"/> and therefore keeps asking for the
|
||||
/// passphrase — which is the honest answer rather than a degraded one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>"Desktop", because the choice belongs to a head rather than to the session layer.</b> This file used
|
||||
/// to live in <c>DodoSSH.Client.Session</c>, which was the one thing keeping that project 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
|
||||
/// makes the move free is <see cref="IDeviceKeyStore"/>, which was already there — the session takes a
|
||||
/// store and has never known which one. See <c>docs/android-port.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class DeviceKeyStores
|
||||
public static class DesktopDeviceKeyStores
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
public static IDeviceKeyStore ForThisMachine(ClientPaths paths)
|
||||
@@ -36,8 +36,10 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
One heading, for one vault. The chevron folds the list away; the count is the collection's own, so it
|
||||
follows the filter without a second number to keep in step.
|
||||
One heading, which names the vault while there is one and says ALL VAULTS once a team's is readable
|
||||
too — a heading that went on naming the personal vault over a list containing a team's hosts would be
|
||||
a quiet lie, so the rows carry the vault name instead. The chevron folds the list away; the count is
|
||||
the collection's own, so it follows the filter without a second number to keep in step.
|
||||
-->
|
||||
<Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
@@ -65,10 +67,37 @@
|
||||
-->
|
||||
<ListBox Grid.Row="2" x:Name="HostList" Focusable="True"
|
||||
IsVisible="{Binding AreHostsExpanded}"
|
||||
ItemsSource="{Binding VisibleHosts}"
|
||||
SelectedItem="{Binding SelectedHost}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
ItemsSource="{Binding SidebarRows}"
|
||||
SelectedItem="{Binding SelectedSidebarRow}">
|
||||
|
||||
<!--
|
||||
Two kinds of row in one list, chosen by type. It has to be one ListBox: it owns the selection and it
|
||||
is where keyboard focus lands when the terminal gives it back, neither of which survives a list per
|
||||
group. A vault with no groups produces no heading rows at all, so this is the list it always was.
|
||||
|
||||
The heading is a row rather than a container, which means the control will happily select it. That is
|
||||
turned back into the previous host selection in the view model — see SelectedSidebarRow — because
|
||||
CONNECT, EDIT and DELETE all act on a host and a highlighted heading is not one.
|
||||
-->
|
||||
<ListBox.DataTemplates>
|
||||
|
||||
<DataTemplate DataType="vm:SidebarGroupHeader">
|
||||
<Button Classes="flat grouphead" Command="{Binding $parent[ListBox].((vm:VaultViewModel)DataContext).ToggleGroupCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Chevron}" Foreground="{StaticResource TextFaint}"
|
||||
FontSize="8" VerticalAlignment="Center" Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="{Binding Label}"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Count}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="vm:HostRowViewModel">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*" Margin="0,5,10,5">
|
||||
|
||||
<!-- The accent strip a selected row carries; see the style in App.axaml. -->
|
||||
@@ -102,11 +131,20 @@
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
<!--
|
||||
Which vault this host is in, and only when there is more than one to be in. It decides
|
||||
who else can see the host and where an edit goes back to, so on a list that spans
|
||||
several vaults it is not decoration.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
IsVisible="{Binding HasVaultBadge}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
|
||||
</ListBox.DataTemplates>
|
||||
</ListBox>
|
||||
|
||||
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
|
||||
@@ -148,6 +186,20 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!--
|
||||
Which group this host is filed under. Inside the encrypted payload like everything else here, so
|
||||
the server learns nothing about how the estate is organised — and a group the vault no longer has
|
||||
keeps a placeholder entry, so that editing the port cannot quietly unfile the host.
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorGroupChoices}"
|
||||
SelectedItem="{Binding EditorSelectedGroup}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:GroupChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<CheckBox IsChecked="{Binding EditorRelayEnabled}"
|
||||
Content="Connect through the server relay" />
|
||||
<!--
|
||||
@@ -193,7 +245,7 @@
|
||||
-->
|
||||
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsConfirmingDeletion}">
|
||||
IsVisible="{Binding IsConfirmingHostDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.HostsScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The hosts screen: the list of machines, and what this application has to say about the one that is
|
||||
selected.
|
||||
|
||||
It used to be the list beside a terminal, and the terminal is no longer here. The tab strip is above
|
||||
every screen now, so a terminal is a surface the whole window switches to rather than a column on this
|
||||
one — see MainWindowViewModel.ShellSurface. What that leaves this screen is the thing its name always
|
||||
promised: an overview.
|
||||
|
||||
In its own file, rather than left in MainWindow.axaml, because nothing inside that window can be laid
|
||||
out by a test — WebView2's adapter refuses the headless session's thread — so markup that stays there
|
||||
is markup nobody can measure. The four blocks in the right column are exactly the ones that most needed
|
||||
measuring: two host key prompts and a conflict log, all three of which appear only in states a person
|
||||
has to reproduce by hand.
|
||||
|
||||
Its data context is the shell, not the vault, so that the sidebar can be handed the vault and everything
|
||||
else can bind Vault.* — the same split MainWindow.axaml had. See MainWindow.axaml's own note on why the
|
||||
two cannot be put on one element.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Sidebar" DataContext="{Binding Vault}" />
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<!--
|
||||
Connecting. A password box only for a host that asks to be — a host bound to a stored credential or
|
||||
a key wants nothing typed here — and a sentence in its place when it does not, because "nothing
|
||||
needs typing" and "something needs typing and the box has not appeared yet" look identical and only
|
||||
one of them is fine.
|
||||
-->
|
||||
<Border Grid.Row="0" Padding="12,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Keychain and bind this host to it in the host's own editor." />
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="1">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the other is
|
||||
a refusal. Presenting a changed key with a "continue" button is how users are taught to click
|
||||
through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode is kept
|
||||
and shown; without this panel it would be last-writer-wins with a longer explanation.
|
||||
|
||||
Bounded and scrollable, which it was not while it lived in the window. It sits on an Auto row above
|
||||
a star row, and an ItemsControl with no ceiling grows without limit — so a pass that merged twenty
|
||||
items pushed everything below it off the bottom of a screen nobody could scroll. It went unnoticed
|
||||
for as long as it did because no test could lay this markup out; that is the other half of why this
|
||||
file exists.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
|
||||
<ScrollViewer MaxHeight="180" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
|
||||
CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
||||
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The overview proper: what is known about the host the list has selected.
|
||||
|
||||
Every fact here is one the sidebar already computes, and that is deliberate. This column was a
|
||||
terminal until this screen stopped hosting one, and filling it with something that needed new state
|
||||
would be inventing a feature to fill a rectangle. What it is for is the question the screen now has
|
||||
to answer — "which machine is this, and how will it let me in" — before the answer scrolls past in a
|
||||
list of forty.
|
||||
-->
|
||||
<ScrollViewer Grid.Row="2" HorizontalScrollBarVisibility="Disabled">
|
||||
<Panel Margin="24">
|
||||
|
||||
<StackPanel Spacing="10" HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="heading" Text="{Binding Vault.SelectedHost.Label}"
|
||||
VerticalAlignment="Center" />
|
||||
<Border Classes="chip" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHost.IsConnected}">
|
||||
<TextBlock Text="CONNECTED" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.SelectedHost.Address}"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding Vault.SelectedHost.Authentication}" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" MaxWidth="440" TextWrapping="Wrap"
|
||||
Text="Press CONNECT, or double-click the host in the list. The terminal opens in the strip above and stays there while you look at anything else." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" HorizontalAlignment="Left" VerticalAlignment="Top"
|
||||
MaxWidth="440" TextWrapping="Wrap"
|
||||
Text="Choose a host on the left to see what it is and how it authenticates. Ctrl+K searches them by name."
|
||||
IsVisible="{Binding Vault.SelectedHost, Converter={x:Static ObjectConverters.IsNull}}" />
|
||||
|
||||
</Panel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
Groups: making them, renaming them, and taking them away.
|
||||
|
||||
Here rather than on the Keychain screen, because a group is not a secret — it is how this screen's
|
||||
list is arranged, and the arranging belongs beside the thing arranged. Filing a host into one is done
|
||||
in the host's own editor, on the left, for the same reason its key and its password are.
|
||||
|
||||
One text box for both adding and renaming. A group has exactly one field, so a separate rename form
|
||||
would be this box with a different heading; GroupSaveLabel is what says which of the two is about to
|
||||
happen.
|
||||
-->
|
||||
<Border Grid.Row="3" Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0">
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Classes="hint" FontSize="10.5" VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
Text="Headings for the list on the left. Which group a host is in is part of the host, and stays encrypted." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Horizontal, because a group is a name and a count: a vertical list of one-line rows would take a
|
||||
third of this column to say what a row of chips says in one line.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"
|
||||
IsVisible="{Binding Vault.HasGroups}">
|
||||
<ListBox ItemsSource="{Binding Vault.Groups}" SelectedItem="{Binding Vault.SelectedGroup}"
|
||||
Background="Transparent" MaxHeight="72">
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
|
||||
<StackPanel Margin="2,4" Spacing="1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" Foreground="{StaticResource Text}"
|
||||
FontSize="11.5" />
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</ScrollViewer>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding Vault.ShowsGroupActions}">
|
||||
<TextBox Text="{Binding Vault.GroupEditorLabel}" PlaceholderText="group name" Width="180"
|
||||
FontSize="11" MinHeight="26" Padding="8,3" />
|
||||
<Button Classes="ghost" Content="{Binding Vault.GroupSaveLabel}"
|
||||
Command="{Binding Vault.SaveGroupCommand}" />
|
||||
<Button Classes="ghost" Content="RENAME SELECTED" Command="{Binding Vault.EditGroupCommand}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding Vault.DeleteGroupCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Swapped for the buttons rather than stacked under them, as the sidebar's own question is, so
|
||||
DELETE cannot be pressed again while its answer is on screen. It asks its own question only: the
|
||||
two panels share one pending deletion, and the sidebar checks the same way.
|
||||
-->
|
||||
<Border Padding="8" Background="{StaticResource DangerWash}" CornerRadius="4"
|
||||
IsVisible="{Binding Vault.IsConfirmingGroupDeletion}">
|
||||
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,27 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The hosts screen: the host list, and an overview of the one that is selected.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the shell rather than the vault, unlike <see cref="HostSidebar"/> and
|
||||
/// <see cref="VaultScreen"/>. The sidebar is handed the vault from inside the markup; everything else here
|
||||
/// reaches it through <c>Vault.*</c>. That split is not tidiness — this element's visibility is the shell's
|
||||
/// business and the sidebar's bindings are the vault's, and an element carrying both resolves the first
|
||||
/// against the second, where it does not exist.
|
||||
/// </remarks>
|
||||
internal sealed partial class HostsScreen : UserControl
|
||||
{
|
||||
public HostsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// Forwarded to the sidebar, which answers for itself: the host list can be folded away, and
|
||||
/// <c>Focus()</c> on a collapsed control is measurably a no-op that is not replayed when the control is
|
||||
/// revealed. Nothing in the right column can take the keyboard — it is a heading and three sentences.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => Sidebar.KeyboardTarget;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.ImportScreen"
|
||||
x:DataType="vm:ImportViewModel">
|
||||
|
||||
<!--
|
||||
Importing ~/.ssh/config.
|
||||
|
||||
A preview and then a button, rather than one action, and that is the whole design. This reads a file
|
||||
the application did not write, out of the user's home directory, and a real ssh_config often holds
|
||||
forty entries for machines that stopped existing years ago. So scanning writes nothing and the list
|
||||
says what each entry means; importing is a separate press on a set somebody has looked at.
|
||||
|
||||
Reachable from the preferences screen and not from the nav rail. It is a task rather than a
|
||||
destination — done once, or once a year — and a seventh rail entry would cost every screen a slot for
|
||||
something almost nobody is looking at.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="IMPORT SSH CONFIG" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ConfigPath}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Button Grid.Column="2" Classes="ghost" Content="SCAN" Command="{Binding ScanCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Reads the file and shows what it found. Nothing is stored." />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding Status}" FontSize="11" Margin="14,12,14,0"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<!--
|
||||
What could not be honoured, above the list rather than beside it. Every one of these is a way the
|
||||
import is quieter than the file — an ignored Match block, a dropped ProxyCommand — and a person
|
||||
comparing the two needs to be told before they conclude the parser lost something.
|
||||
-->
|
||||
<Border Grid.Row="2" Margin="14,12,14,0" Padding="10,8" CornerRadius="4"
|
||||
Background="{StaticResource WarnWash}" BorderBrush="{StaticResource WarnSoft}"
|
||||
BorderThickness="1" IsVisible="{Binding HasWarnings}">
|
||||
<ItemsControl ItemsSource="{Binding Warnings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Text="{Binding}" Foreground="{StaticResource WarnText}" FontSize="10"
|
||||
TextWrapping="Wrap" Margin="0,2" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="3" RowDefinitions="Auto,*" Margin="0,12,0,0" IsVisible="{Binding HasRows}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="14,0,14,6">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="AUTHENTICATION" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STATE" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Rows}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||
<StackPanel Margin="14,0">
|
||||
<Grid ColumnDefinitions="34,1.1*,1.4*,1.6*,96" Margin="0,7">
|
||||
<CheckBox Grid.Column="0" IsChecked="{Binding IsSelected}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Alias}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Authentication}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<Border Grid.Column="4" Classes="chip" HorizontalAlignment="Left"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasBadge}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" Text="{Binding Warnings}" FontSize="9.5" Margin="34,0,0,8"
|
||||
TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||
IsVisible="{Binding HasWarnings}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Row="4" Padding="14,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding HasRows}">
|
||||
<StackPanel Spacing="8">
|
||||
<!--
|
||||
Said before the button, not after. A key path is recorded and the key itself is not read: that is
|
||||
the difference between a bookmark that connects and one that asks for a password, and somebody
|
||||
who is not told will conclude the import was broken.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Key files are not read. Where ssh_config names an IdentityFile the path is recorded as a note, and the host asks for a password until you bind it to a key in your keychain. Nothing here reaches into ~/.ssh for private key material." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="{Binding ImportLabel}" Command="{Binding ImportCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="TICK ALL / NONE" Command="{Binding ToggleAllCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,37 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Primitives;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Importing hosts from <c>~/.ssh/config</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A task rather than a destination, which is why it is reached from preferences and not from the nav rail.
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportScreen : UserControl
|
||||
{
|
||||
public ImportScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// The count on the import button is derived from the ticks, and a CheckBox bound with
|
||||
// {Binding IsSelected} tells its own row and nothing else. Rather than have every row hold a
|
||||
// reference back to the screen, the screen listens for the event they all bubble.
|
||||
AddHandler(ToggleButton.IsCheckedChangedEvent, OnTickChanged, RoutingStrategies.Bubble);
|
||||
}
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
internal IInputElement KeyboardTarget => this;
|
||||
|
||||
private void OnTickChanged(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is ImportViewModel import)
|
||||
{
|
||||
import.NoteSelectionChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.KnownHostsScreen"
|
||||
x:DataType="vm:KnownHostsViewModel">
|
||||
|
||||
<!--
|
||||
The host keys this keychain has approved.
|
||||
|
||||
These were a category on the keychain screen, alongside SSH keys and passwords, and they do not belong
|
||||
there: the other two are things a person creates and edits, and a pin is a decision recorded at the
|
||||
moment of connecting. Nobody goes looking for one in a list of credentials. They are also the only items
|
||||
with a workflow of their own — compare a fingerprint against what the operator published — and that
|
||||
workflow needs a filter and a column layout the shared table could not give them.
|
||||
|
||||
The data layer did not move and did not change. Every pin is still a vault item, still end-to-end
|
||||
encrypted, still synced; see KnownHostSecret. What is here is a screen over VaultViewModel.KnownHostPins.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="*,244">
|
||||
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="HOST KEYS" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Summary}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
Matches fingerprints as well as host names, which is the point of it. What somebody does with
|
||||
this screen is check whether a published SHA256:… is the one they approved, and searching only
|
||||
by name would answer a different question.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="PinFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by host or fingerprint" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,6,14,6"
|
||||
IsVisible="{Binding HasVisiblePins}">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="PORT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="ALGORITHM" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="FINGERPRINT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="APPROVED" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="2" x:Name="PinList" Focusable="True"
|
||||
ItemsSource="{Binding VisiblePins}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:KnownHostRowViewModel">
|
||||
<Grid ColumnDefinitions="2,1.4*,58,104,*,96" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Host}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="12,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Port}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Algorithm}" FontSize="9"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<!--
|
||||
Never trimmed, and this column is why the table is laid out the way it is. The only thing
|
||||
anybody does with a fingerprint is compare it character by character against one an operator
|
||||
published; an ellipsis in the middle turns that into a glance, which is the habit the whole
|
||||
mechanism exists to replace.
|
||||
-->
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Fingerprint}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="5" Classes="mono" Text="{Binding Approved}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="2" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="340"
|
||||
IsVisible="{Binding !HasVisiblePins}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="6">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Choose a pinned key to see it in full, and to withdraw it."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<Border Classes="chip warn" HorizontalAlignment="Left"
|
||||
IsVisible="{Binding !Selected.IsDialledByAHost}">
|
||||
<TextBlock Text="no host uses this" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="FINGERPRINT" Margin="0,12,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Fingerprint}"
|
||||
FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="label" Text="APPROVED" Margin="0,12,0,4" />
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Approved}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
<!--
|
||||
Said rather than implied. No vault item carries a timestamp, so this date is read back out of
|
||||
the item's own version 7 id — which records when the pin was created and knows nothing about
|
||||
it being re-approved since. Presenting that as "last used" would be inventing a fact.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Taken from the item's identifier, so it is when this key was first approved — not when it was last checked. Nothing here records that." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,12,0,0"
|
||||
Text="A pin outlives whatever it was approved for: deleting a host leaves it, and so does changing a host's address. That is deliberate — trust is about the endpoint, not the bookmark." />
|
||||
|
||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" Margin="0,12,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding ForgetSelectedCommand}"
|
||||
ToolTip.Tip="Withdraws trust. The next connection to this endpoint asks you to check the fingerprint again, which is the safe direction to be wrong in — and it is the way back from a server that was legitimately rebuilt." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,26 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The host keys this keychain has approved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>KnownHostsViewModel</c>, which is a screen-scoped wrapper over the vault rather
|
||||
/// than an owner of anything: the pins, the reload and the withdrawal all still belong to
|
||||
/// <c>VaultViewModel</c>. See that class for why.
|
||||
/// </remarks>
|
||||
internal sealed partial class KnownHostsScreen : UserControl
|
||||
{
|
||||
public KnownHostsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// The filter box rather than the list, unlike the keychain screen. This screen is reached to answer a
|
||||
/// question — is this fingerprint one of mine — and the first thing anybody does is type part of it.
|
||||
/// The box is also always there, where the list is empty on a fresh keychain, and <c>Focus()</c> on a
|
||||
/// collapsed control is a no-op that is not replayed.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => PinFilter;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.LogsScreen"
|
||||
x:DataType="vm:LogsViewModel">
|
||||
|
||||
<!--
|
||||
What has been connected to, and what has been changed.
|
||||
|
||||
Two logs behind one screen, chosen by two buttons rather than by a selector's selection — the same idiom
|
||||
the keychain screen's categories use, and for the same reason: a selection binding moves before a command
|
||||
can refuse it.
|
||||
|
||||
Both are ordinary synced keychain items, encrypted like everything else. The server holds them and cannot
|
||||
read a single field; what it does learn is that rows exist and when they were written, which ADR 0001
|
||||
records as the metadata this design cannot hide.
|
||||
|
||||
The connections list shows anything still open at the top, marked "still open" rather than with a dash. A
|
||||
dash would read as a missing recording, and the two are opposite facts — an entry is written once, when a
|
||||
connection closes, so a live session is deliberately not in the vault yet.
|
||||
-->
|
||||
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,Auto,Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="LOGS" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,14,0" />
|
||||
|
||||
<Button Grid.Column="1" Classes="flat cat" Content="CONNECTIONS"
|
||||
Classes.active="{Binding ShowsConnections}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Connections}" />
|
||||
<Button Grid.Column="2" Classes="flat cat" Content="KEYCHAIN"
|
||||
Classes.active="{Binding ShowsActivity}"
|
||||
Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:LogSection.Activity}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Status}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="14,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<Button Grid.Column="4" Classes="ghost" Content="REFRESH" Command="{Binding RefreshCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Connections ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsConnections}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasConnections}">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="ADDRESS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="LASTED" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="KIND" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="STARTED" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="5" Classes="label" Text="FROM" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ConnectionList" Focusable="True"
|
||||
ItemsSource="{Binding Connections}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,1.6*,88,72,90,*" Margin="0,6,14,6">
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6" Margin="14,0,8,0">
|
||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostLabel}" FontSize="11" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding Duration}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Kind}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Started}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="5" Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="mono" Text="{Binding DeviceName}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
Only when there is something to say. A connection that opened and closed says nothing
|
||||
here; one that was refused says so, and that is the row worth finding in a long list.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding HasOutcome}">
|
||||
<TextBlock Text="{Binding Outcome}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasConnections}" />
|
||||
</Grid>
|
||||
|
||||
<!-- ============ Keychain changes ============ -->
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,*" IsVisible="{Binding ShowsActivity}">
|
||||
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6"
|
||||
IsVisible="{Binding HasActivity}">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="ITEM" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="TYPE" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="WHAT" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="FIELDS" FontSize="8.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="WHEN" FontSize="8.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="ActivityList" Focusable="True" ItemsSource="{Binding Activity}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ActivityLogRowViewModel">
|
||||
<Grid ColumnDefinitions="1.2*,90,96,*,90" Margin="14,6,14,6">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding ItemLabel}" FontSize="11"
|
||||
FontWeight="Medium" Foreground="{StaticResource Text}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding ItemKind}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Operation}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
The names of the fields that changed, and never what they changed to. A log that recorded
|
||||
an old password would be a plaintext credential store with a vault drawn around it.
|
||||
-->
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding ChangedFields}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="0,0,8,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"
|
||||
IsVisible="{Binding HasChangedFields}" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding At}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="420"
|
||||
IsVisible="{Binding !HasActivity}" />
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,28 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// What has been connected to, and what has been changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>LogsViewModel</c>, a screen-scoped wrapper over the open session. Both logs are
|
||||
/// ordinary synced keychain items; nothing about them is local.
|
||||
/// </remarks>
|
||||
internal sealed partial class LogsScreen : UserControl
|
||||
{
|
||||
public LogsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// Whichever list is on screen, because this screen has no filter box and a collapsed control cannot
|
||||
/// take focus — <c>Focus()</c> on one is a no-op that nothing replays when it is revealed. The lists are
|
||||
/// focusable explicitly for the same reason the host list is: Avalonia leaves focus to the items, and an
|
||||
/// empty list has none.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget =>
|
||||
DataContext is LogsViewModel { ShowsActivity: true } ? ActivityList : ConnectionList;
|
||||
}
|
||||
@@ -15,7 +15,8 @@
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar.
|
||||
The shell window: a titlebar it draws itself, a nav rail, a tab strip, one surface at a time, and a
|
||||
status bar.
|
||||
|
||||
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
||||
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
||||
@@ -28,6 +29,13 @@
|
||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||
chrome worth having.
|
||||
|
||||
TWO SURFACES, ONE RECTANGLE.
|
||||
|
||||
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
|
||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
||||
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
|
||||
enum rather than two flags, so there is no way to write the state where both are showing.
|
||||
|
||||
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
||||
|
||||
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
|
||||
@@ -36,15 +44,16 @@
|
||||
buttons unreachable, which this window has shipped once already.
|
||||
|
||||
So anything that would occupy the terminal's rectangle collapses the terminal instead, and
|
||||
IsTerminalShowing is the one place that decision is made: a locked vault, a screen other than Hosts, or
|
||||
the quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
||||
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, or the
|
||||
quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
||||
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
|
||||
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
|
||||
only swaps ShowInBounds for HideWithSize, and flipping it back re-pushes the bounds.
|
||||
|
||||
What the first connection after unlocking actually depends on is the await in
|
||||
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate is
|
||||
that await, never this control's visibility.
|
||||
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
|
||||
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
|
||||
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
|
||||
only chooses between the pages.
|
||||
|
||||
Two nearby alternatives are wrong. Removing the control from the tree instead — conditional content, a
|
||||
template swap — detaches it, and detaching destroys the native control and the whole WebView2 process
|
||||
@@ -64,167 +73,111 @@
|
||||
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
<!--
|
||||
The rail is full height and the strip is not, so the strip spans exactly the area it navigates.
|
||||
The other arrangement — strip above rail — would put a row of tabs over a column of destinations
|
||||
they have nothing to do with.
|
||||
-->
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||
|
||||
<!-- ============ HOSTS + TERMINAL ============ -->
|
||||
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" />
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
<!-- ============ THE PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
|
||||
<!--
|
||||
Connecting. A password box only for a host that asks to be — a host bound to a stored
|
||||
credential or a key wants nothing typed here — and a sentence in its place when it does not,
|
||||
because "nothing needs typing" and "something needs typing and the box has not appeared yet"
|
||||
look identical and only one of them is fine.
|
||||
Bound directly rather than wrapped, unlike the two below it: this screen's data context is
|
||||
the shell's, so IsHostsScreen resolves. It hands the vault to the sidebar from inside its
|
||||
own markup.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="12,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Vault and bind this host to it in the host's own editor." />
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2">
|
||||
|
||||
<!--
|
||||
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
||||
other is a refusal. Presenting a changed key with a "continue" button is how users are
|
||||
taught to click through the one warning that matters.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasPendingHostKey}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The conflict log. The merge is only allowed to pick a winner because the value it overrode
|
||||
is kept and shown; without this panel it would be last-writer-wins with a longer
|
||||
explanation.
|
||||
-->
|
||||
<Border Padding="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
|
||||
CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
||||
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
<views:HostsScreen x:Name="HostsPane" IsVisible="{Binding IsHostsScreen}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
||||
process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
||||
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
||||
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
||||
does.
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and
|
||||
putting both on one element resolves IsVisible against that view model, where
|
||||
IsTransfersScreen does not exist.
|
||||
-->
|
||||
<NativeWebView Grid.Row="3" x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
<!-- ============ KEYCHAIN ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this
|
||||
element's visibility is the shell's business and its data context is the vault, and put both
|
||||
on one element and IsVisible resolves against the vault as well, where IsVaultScreen does
|
||||
not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultScreen}">
|
||||
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ HOST KEYS ============ -->
|
||||
<!--
|
||||
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
||||
view model, where IsKnownHostsScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
||||
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
||||
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsLogsScreen}">
|
||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<!--
|
||||
Wrapped, for the reason the vault and transfers screens are: the visibility is the shell's
|
||||
business and the data context is the teams view model, and both on one element would resolve
|
||||
IsTeamScreen against a type that does not have it.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTeamScreen}">
|
||||
<views:TeamsScreen DataContext="{Binding Teams}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
<!-- ============ IMPORT ============ -->
|
||||
<!--
|
||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
||||
the others whose data context is their own view model.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportScreen}">
|
||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser
|
||||
process tree, so twenty tabs would cost twenty of them.
|
||||
|
||||
A sibling of the page area rather than a child of any screen, which is the structural half of
|
||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
||||
|
||||
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible
|
||||
then falls back to its default of true, and the occlusion comes back silently. Not reachable
|
||||
at runtime — the DataContext is set before the window is shown — but it is what the previewer
|
||||
does.
|
||||
-->
|
||||
<NativeWebView x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and putting
|
||||
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
|
||||
exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this element's
|
||||
visibility is the shell's business and its data context is the vault, and put both on one element
|
||||
and IsVisible resolves against the vault as well, where IsVaultScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultScreen}">
|
||||
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
|
||||
Title="TEAM"
|
||||
Milestone="MILESTONE M3"
|
||||
Summary="The design shows members, roles, shared vaults and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every vault that is not your own — so there is nobody to list and no shared vault to open."
|
||||
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Vault screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
|
||||
<x:String>Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
|
||||
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
|
||||
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
|
||||
<x:String>Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -262,12 +215,12 @@
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
|
||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Classes="accent" Content="CREATE MY VAULT" Command="{Binding EnrollCommand}"
|
||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
@@ -275,14 +228,14 @@
|
||||
|
||||
<!--
|
||||
Shown once and impossible to skip. This is the only moment the code exists, and losing it
|
||||
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
|
||||
design.
|
||||
together with the passphrase means the keychain is unrecoverable — there is no server-side reset
|
||||
by design.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: nobody — including whoever runs the server — can recover it for you." />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
@@ -66,14 +67,56 @@ internal sealed partial class MainWindow : Window
|
||||
/// keyboard nowhere: focus does not stay where it was, because collapsing the control it was on clears
|
||||
/// it outright, and the fallback's own <c>Focus()</c> call was failing silently.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The terminal answers first, and it has to, because <see cref="MainWindowViewModel.Screen"/> still
|
||||
/// names a page while a terminal is showing — that is the point of it. Asking the screen would hand the
|
||||
/// keyboard to a host list nobody can see.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IInputElement KeyboardHome => shell?.Screen switch
|
||||
private IInputElement KeyboardHome => shell switch
|
||||
{
|
||||
ShellScreen.Vault => VaultPane.KeyboardTarget,
|
||||
ShellScreen.Hosts => Hosts.KeyboardTarget,
|
||||
{ IsTerminalShowing: true } => Terminal,
|
||||
{ Screen: ShellScreen.Vault } => VaultPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Hosts } => HostsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.KnownHosts } => PinsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Import } => ImportPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Snippets } => SnippetsPane.KeyboardTarget,
|
||||
{ Screen: ShellScreen.Logs } => LogsPane.KeyboardTarget,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Asks for the terminal to take the keyboard, once layout has run.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Posted, not called.</b> Every path that reaches here has revealed the WebView in this same turn —
|
||||
/// a session opened from another screen, a tab clicked while a page was showing, the palette closing
|
||||
/// back onto a terminal. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so
|
||||
/// focusing microseconds ahead of that pass races exactly the thing the focus depends on, and the
|
||||
/// symptom is silent: a terminal that looks selected and receives nothing until it is clicked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <c>DispatcherPriority.Loaded</c> runs after layout. It is the same fix and the same reasoning as
|
||||
/// <see cref="QuickConnect"/>'s, which posts its own focus for the same race in the other direction.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Re-checked inside the post rather than trusted from outside it, because a turn is long enough for the
|
||||
/// user to have navigated away — closing the last tab, or clicking the rail — and stealing the keyboard
|
||||
/// into a collapsed WebView would leave the window with nothing focused at all.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void FocusTerminalWhenLaidOut() =>
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (shell is { IsTerminalShowing: true })
|
||||
{
|
||||
Terminal.Focus();
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard belongs once the vault is no longer open.
|
||||
/// </summary>
|
||||
@@ -160,14 +203,19 @@ internal sealed partial class MainWindow : Window
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A bare <c>Focus()</c> is the whole fix in this direction: <c>NativeWebView.OnGotFocus</c> pushes
|
||||
/// Win32 focus into WebView2 for us. It has to happen while the control is visible, which it is —
|
||||
/// a session can only be opened from the hosts screen of an unlocked vault, and that is exactly the
|
||||
/// state in which the terminal is showing. Focus() on a collapsed control is measurably a no-op and is
|
||||
/// not replayed when it is revealed.
|
||||
/// <c>NativeWebView.OnGotFocus</c> pushes Win32 focus into WebView2 for us, so a <c>Focus()</c> call is
|
||||
/// the whole fix in this direction — but it has to happen while the control is visible, and it no longer
|
||||
/// reliably is at this instant. A session can now be opened from any screen, so this event routinely
|
||||
/// arrives in the same turn that revealed the WebView. Hence the post; see
|
||||
/// <see cref="FocusTerminalWhenLaidOut"/>.
|
||||
/// </remarks>
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => Terminal.Focus();
|
||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||
|
||||
/// <remarks>
|
||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||
/// point at which "does this one return early" stops being obvious to a reader.
|
||||
/// </remarks>
|
||||
private void OnShellPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (shell is not { } viewModel)
|
||||
@@ -175,54 +223,117 @@ internal sealed partial class MainWindow : Window
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsUnlocked), StringComparison.Ordinal))
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
case nameof(MainWindowViewModel.IsUnlocked):
|
||||
OnVaultOpenedOrClosed(viewModel);
|
||||
break;
|
||||
|
||||
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
case nameof(MainWindowViewModel.IsSearching):
|
||||
OnPaletteToggled(viewModel);
|
||||
break;
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
// One arm for both, deliberately. They mean the same thing to this handler — what the window is
|
||||
// showing may have changed — and answering them separately would make the order of two
|
||||
// PropertyChanged raises decide the outcome. Connecting from the palette moves both.
|
||||
case nameof(MainWindowViewModel.Surface):
|
||||
case nameof(MainWindowViewModel.Screen):
|
||||
OnShowingSomethingElse(viewModel);
|
||||
break;
|
||||
|
||||
case nameof(MainWindowViewModel.SelectedTab):
|
||||
OnSelectedTabChanged(viewModel);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVaultOpenedOrClosed(MainWindowViewModel viewModel)
|
||||
{
|
||||
var unlocked = viewModel.IsUnlocked;
|
||||
|
||||
// Only the transition out of unlocked matters. IsUnlocked is re-raised for every shell state
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||
/// which is a moment this handler is measurably ahead of: it runs from the view model's
|
||||
/// <c>PropertyChanged</c>, before the binding that reveals the control, and <c>Focus()</c> on a control
|
||||
/// that is still collapsed is a no-op that is not replayed when it is revealed.
|
||||
/// </remarks>
|
||||
private void OnPaletteToggled(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.IsSearching)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is
|
||||
// expected to start typing into immediately — but the palette does that for itself when it becomes
|
||||
// visible, which is a moment this handler is measurably ahead of: it runs from the view model's
|
||||
// PropertyChanged, before the binding that reveals the control, and Focus() on a control that is
|
||||
// still collapsed is a no-op that is not replayed when it is revealed.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.IsSearching), StringComparison.Ordinal))
|
||||
// Closing the palette over a terminal reveals the WebView in this same turn, so it needs the posted
|
||||
// focus rather than the immediate one.
|
||||
if (viewModel.IsTerminalShowing)
|
||||
{
|
||||
if (!viewModel.IsSearching)
|
||||
{
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
else
|
||||
{
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves the keyboard when the window swaps a page for a terminal, or one page for another.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The most common gesture in the window now that the strip spans every screen: a tab and a rail entry
|
||||
/// are both one click away at all times.
|
||||
/// <para>
|
||||
/// <c>ReleaseKeyboardTo</c>, not <c>Focus()</c>, in the page direction — and that is the whole of why
|
||||
/// this method is worth reading. <b>Collapsing the WebView does not release the keyboard.</b> The native
|
||||
/// child window goes on holding Win32 focus, Avalonia then sees no key events at all, and the screen
|
||||
/// that just appeared silently swallows every keystroke. It was a latent defect while leaving a terminal
|
||||
/// was rare; it is the hot path now. See <c>docs/platform-flags.md</c>, and
|
||||
/// <see cref="NativeKeyboardFocus"/> for why only one direction needs the Win32 call.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnShowingSomethingElse(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (!viewModel.IsUnlocked)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Switching screens moves the keyboard to whatever the new screen offers, for the same reason:
|
||||
// leaving it on a control that has just been collapsed leaves the window with nothing focused.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.Screen), StringComparison.Ordinal)
|
||||
&& viewModel.IsUnlocked)
|
||||
if (viewModel.IsTerminalShowing)
|
||||
{
|
||||
KeyboardHome.Focus();
|
||||
return;
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
|
||||
// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click
|
||||
// is what took the WebView's Win32 focus away in the first place. term.focus() in the page only
|
||||
// ever reaches document.activeElement, which does nothing for a page that no longer holds the
|
||||
// native focus, so without this the pane looks selected and every keystroke goes to the button
|
||||
// instead of the shell until the user clicks inside the terminal by hand.
|
||||
if (string.Equals(e.PropertyName, nameof(MainWindowViewModel.SelectedTab), StringComparison.Ordinal)
|
||||
&& viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
|
||||
else
|
||||
{
|
||||
Terminal.Focus();
|
||||
ReleaseKeyboardTo(KeyboardHome);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Clicking a tab moves both Win32 and Avalonia focus onto the button that was clicked — the click is
|
||||
/// what took the WebView's Win32 focus away in the first place. <c>term.focus()</c> in the page only
|
||||
/// ever reaches <c>document.activeElement</c>, which does nothing for a page that no longer holds the
|
||||
/// native focus, so without this the pane looks selected and every keystroke goes to the button instead
|
||||
/// of the shell until the user clicks inside the terminal by hand.
|
||||
/// </remarks>
|
||||
private void OnSelectedTabChanged(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.SelectedTab is not null && viewModel.IsTerminalShowing)
|
||||
{
|
||||
FocusTerminalWhenLaidOut();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
Five destinations down the left edge.
|
||||
Six destinations down the left edge.
|
||||
|
||||
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
|
||||
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
|
||||
@@ -16,6 +16,12 @@
|
||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
||||
anything. Buttons carry no state and cannot disagree with the screen that is showing.
|
||||
|
||||
Lit from IsXShowing and not from IsXScreen, which are different questions now that the tab strip spans
|
||||
every screen. A terminal opened from here leaves Screen on Hosts — deliberately, so closing the tab comes
|
||||
back — and a rail entry lit while a terminal filled the window would be pointing at a screen that is not
|
||||
showing. So nothing here is lit at all while a terminal is up: the selected tab already carries that
|
||||
mark, in the strip, and two "you are here" marks is one too many.
|
||||
-->
|
||||
|
||||
<Border Width="54" Background="{StaticResource Chrome}"
|
||||
@@ -23,26 +29,44 @@
|
||||
<DockPanel LastChildFill="False">
|
||||
|
||||
<StackPanel DockPanel.Dock="Top" Margin="0,8,0,0">
|
||||
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsScreen}"
|
||||
<Button Classes="flat nav" Content="HOSTS" Classes.active="{Binding IsHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Hosts}"
|
||||
ToolTip.Tip="Your hosts, and the terminals open on them" />
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
|
||||
ToolTip.Tip="Your hosts, and what is known about the one you have selected" />
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
|
||||
ToolTip.Tip="Move files to and from a host over SFTP" />
|
||||
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
|
||||
<Button Classes="flat nav" Content="KEYS" Classes.active="{Binding IsVaultShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}"
|
||||
ToolTip.Tip="SSH keys, stored passwords, and the host keys you have approved" />
|
||||
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamScreen}"
|
||||
ToolTip.Tip="Your keychain: SSH keys, stored passwords, and the host keys you have approved" />
|
||||
<!--
|
||||
PINS, not HOST KEYS. The rail is 54 pixels wide at mono FontSize 9, which is five characters —
|
||||
and "pins" is what this codebase calls them everywhere else anyway.
|
||||
-->
|
||||
<Button Classes="flat nav" Content="PINS" Classes.active="{Binding IsKnownHostsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one" />
|
||||
<!-- SNIPS, for the same five-character reason as PINS above. -->
|
||||
<Button Classes="flat nav" Content="SNIPS" Classes.active="{Binding IsSnippetsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Snippets}"
|
||||
ToolTip.Tip="Commands you have saved, and how to put one into a terminal" />
|
||||
<!-- LOGS, four characters, so it needs no abbreviating at all. -->
|
||||
<Button Classes="flat nav" Content="LOGS" Classes.active="{Binding IsLogsShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Logs}"
|
||||
ToolTip.Tip="What has been connected to, and what has been changed in this keychain" />
|
||||
<Button Classes="flat nav" Content="TEAM" Classes.active="{Binding IsTeamShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Team}"
|
||||
ToolTip.Tip="Shared vaults and the people in them. Not built yet — see the screen for what is missing." />
|
||||
ToolTip.Tip="Shared keychains and the people in them. Not built yet — see the screen for what is missing." />
|
||||
</StackPanel>
|
||||
|
||||
<Button DockPanel.Dock="Bottom" Classes="flat nav" Content="PREFS"
|
||||
Classes.active="{Binding IsPreferencesScreen}"
|
||||
Classes.active="{Binding IsPreferencesShowing}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Preferences}"
|
||||
ToolTip.Tip="Preferences, and this machine's device key" />
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
|
||||
Command="{Binding RegisterDeviceCommand}"
|
||||
@@ -54,20 +54,20 @@
|
||||
|
||||
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
|
||||
<TextBlock Classes="hint" FontSize="10" Margin="0,8,0,0"
|
||||
Text="This machine has nowhere to keep a device key, so the vault will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="VAULT" FontSize="13" FontWeight="SemiBold"
|
||||
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Lock the vault" Foreground="{StaticResource Text}" FontSize="12"
|
||||
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Closes the vault and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the vault, not this machine's access to your hosts." />
|
||||
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
|
||||
</Grid>
|
||||
@@ -77,7 +77,7 @@
|
||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
@@ -87,6 +87,18 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Import}" />
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
|
||||
@@ -100,7 +112,7 @@
|
||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
||||
@@ -137,7 +149,7 @@
|
||||
<TextBlock Classes="gap"
|
||||
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the vault." />
|
||||
Text="Any preference at all, saved — there is no preferences store in the local cache and no preference item type in the keychain." />
|
||||
<TextBlock Classes="gap"
|
||||
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
|
||||
<TextBlock Classes="gap"
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." />
|
||||
Text="This deletes this machine's copy of the keychain — the profile, the cached hosts, keys and passwords, and this machine's device key. Your keychain is on the server and is not touched: signing in again brings it all back." />
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SnippetsScreen"
|
||||
x:DataType="vm:SnippetsViewModel">
|
||||
|
||||
<!--
|
||||
Commands somebody has saved, and how to get one into a terminal.
|
||||
|
||||
The list and the writing belong to the vault, as every other item kind's do; this screen is the filter,
|
||||
the editor and the insert over the top. See SnippetsViewModel.
|
||||
|
||||
The two buttons at the bottom right are the whole safety design, and their wording is load-bearing.
|
||||
A terminal is one input stream with no notion of being at a prompt — the remote may be inside vi, or at
|
||||
a sudo password prompt with echo off — so this application cannot say "run this command", only "type
|
||||
this into whatever is there". RUN appears solely for a snippet whose own flag says it runs, which makes
|
||||
that a decision taken once while writing it rather than a button beside every one of them.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="*,300">
|
||||
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="SNIPPETS" FontSize="11"
|
||||
FontWeight="SemiBold" LetterSpacing="1" Foreground="{StaticResource Text}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Status}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
The command is searched as well as the name: half of what anybody remembers about a saved
|
||||
command is a word that was inside it.
|
||||
-->
|
||||
<TextBox Grid.Column="2" x:Name="SnippetFilter" Text="{Binding Filter}" Width="240"
|
||||
PlaceholderText="filter by name or command" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ListBox Grid.Row="1" x:Name="SnippetList" Focusable="True"
|
||||
ItemsSource="{Binding Visible}"
|
||||
SelectedItem="{Binding Selected}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SnippetRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*" Margin="0,7,14,7">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<StackPanel Grid.Column="1" Margin="12,0,0,0" Spacing="2">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<!--
|
||||
The flag, where the decision is made. A snippet that presses Enter for you is not the
|
||||
same kind of thing as one that does not, and the list is where somebody chooses between
|
||||
them.
|
||||
-->
|
||||
<Border Classes="chip warn" Padding="4,0" IsVisible="{Binding RunsOnInsert}">
|
||||
<TextBlock Text="runs immediately" FontSize="8.5" />
|
||||
</Border>
|
||||
<Border Classes="chip warn" Padding="4,0"
|
||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Text="{Binding Badge}" FontSize="8.5" />
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
Newlines shown as ⏎ rather than dropped. A three-line snippet flattened into one run of
|
||||
text reads as a single command, which is the thing being decided about on this row.
|
||||
-->
|
||||
<TextBlock Classes="mono" Text="{Binding Preview}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" Text="{Binding EmptyMessage}" FontSize="11"
|
||||
Margin="24" HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextAlignment="Center" MaxWidth="360"
|
||||
IsVisible="{Binding !HasVisible}" />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,8" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,1,0,0">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="+ NEW SNIPPET" Command="{Binding NewCommand}" />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="14,16" Spacing="8">
|
||||
|
||||
<!-- ============ The editor ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditing}">
|
||||
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
|
||||
<!--
|
||||
Stored exactly as typed — no trimming, no newline normalisation. A here-document's terminator
|
||||
has to arrive on a line of its own, and tidying the trailing newline off it leaves the shell
|
||||
waiting for one that never comes.
|
||||
-->
|
||||
<TextBox Text="{Binding EditorCommand}" PlaceholderText="the command" AcceptsReturn="True"
|
||||
Height="140" TextWrapping="NoWrap" FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="11" />
|
||||
<TextBox Text="{Binding EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="48" TextWrapping="Wrap" />
|
||||
<CheckBox IsChecked="{Binding EditorRunsOnInsert}"
|
||||
Content="Press Enter after inserting this" />
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Off means the command is typed at the prompt and waits for you. That single Enter is the only thing standing between a saved command and a running one, so leave it off unless you meant it." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ============ The selected snippet ============ -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding !IsEditing}">
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Choose a snippet to see it in full and put it into a terminal."
|
||||
IsVisible="{Binding !HasSelection}" />
|
||||
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasSelection}">
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Label}" FontSize="12"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="label" Text="COMMAND" Margin="0,10,0,4" />
|
||||
<Border Background="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Selected.Snippet.Command}"
|
||||
FontSize="9.5" Foreground="{StaticResource TextDim}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding Selected.Snippet.Notes}" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
IsVisible="{Binding Selected.Snippet.Notes, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||
|
||||
<!--
|
||||
The button names the tab it will type into. This screen is not the terminal — the strip
|
||||
above it is — so "INSERT" alone would leave somebody working out which of six open tabs is
|
||||
about to receive a command, at the moment that is worst to be wrong about.
|
||||
-->
|
||||
<Button Classes="accent" Content="{Binding InsertLabel}" Margin="0,14,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Command="{Binding InsertCommand}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command at the prompt and stops. Nothing runs until you press Enter there." />
|
||||
|
||||
<Button Classes="danger" Content="{Binding RunLabel}" HorizontalAlignment="Left"
|
||||
Command="{Binding RunCommand}"
|
||||
IsVisible="{Binding SelectionRuns}" IsEnabled="{Binding CanInsert}"
|
||||
ToolTip.Tip="Types the command and presses Enter. Offered because this snippet is marked as one that runs." />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap" Margin="0,10,0,0"
|
||||
Text="Whatever is in the terminal receives this. Nothing here can tell whether that is a shell prompt, an editor, or a password prompt with the echo off — so check the tab before you insert." />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,24 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The commands this keychain has saved.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is a <c>SnippetsViewModel</c>, a screen-scoped wrapper over the vault rather than an
|
||||
/// owner of anything: the list, the storage and the push all still belong to <c>VaultViewModel</c>.
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsScreen : UserControl
|
||||
{
|
||||
public SnippetsScreen() => InitializeComponent();
|
||||
|
||||
/// <summary>Where the keyboard lands when this screen is the one showing.</summary>
|
||||
/// <remarks>
|
||||
/// The filter box rather than the list, for the reason the pins screen gives: the box is there on a
|
||||
/// keychain with nothing saved yet, where the list is empty and <c>Focus()</c> on it would be a no-op
|
||||
/// nothing replays.
|
||||
/// </remarks>
|
||||
internal IInputElement KeyboardTarget => SnippetFilter;
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TeamsScreen"
|
||||
x:DataType="vm:TeamsViewModel">
|
||||
|
||||
<!--
|
||||
Teams.
|
||||
|
||||
The screen is built around one fact that every other product in this category hides: adding somebody to
|
||||
a team and giving them a vault key are two different acts, and only the first is something a server can
|
||||
do. The second needs a machine that holds the key, because this server never does. So the members table
|
||||
and the vaults table are side by side, an addition says out loud that it granted nothing readable yet,
|
||||
and SHARE KEY is its own button rather than a checkbox on the member row.
|
||||
|
||||
What the design asked for and is still not here: pending invitations (there is no outbound mail path and
|
||||
no invitation token), two-factor state and last-active (the server records neither), and avatars (no
|
||||
picture is stored anywhere). None of them is drawn with invented data.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="268,*">
|
||||
|
||||
<!-- ============ The team list ============ -->
|
||||
<Border Grid.Column="0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,1,0">
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="TEAMS" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW"
|
||||
Command="{Binding NewTeamCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<ListBox ItemsSource="{Binding Teams}" SelectedItem="{Binding SelectedTeam}"
|
||||
Background="Transparent" BorderThickness="0">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Role}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding Detail}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" Margin="14,12" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasTeams}"
|
||||
Text="No teams yet. A team is what makes a vault shareable: its vaults can be opened by every member you wrap a key to." />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- The create form, in place rather than in a modal: this window has no idiom for one. -->
|
||||
<Border Grid.Row="2" Padding="14,12" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsCreatingTeam}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="NEW TEAM" />
|
||||
<TextBox PlaceholderText="Name" Text="{Binding NewTeamName}" />
|
||||
<TextBox PlaceholderText="slug-for-urls" Text="{Binding NewTeamSlug}" />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="The slug is lowercase letters, digits and hyphens, and has to be unique across this server." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="CREATE" Command="{Binding CreateTeamCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelNewTeamCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ Members and vaults ============ -->
|
||||
<Grid Grid.Column="1" RowDefinitions="44,*,Auto">
|
||||
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Classes="mono" Text="{Binding SelectedTeam.Name}" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1" IsVisible="{Binding HasSelection}">
|
||||
<StackPanel Margin="14,14" Spacing="18">
|
||||
|
||||
<!-- Members -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="label" Text="MEMBERS" />
|
||||
|
||||
<ListBox ItemsSource="{Binding Members}" SelectedItem="{Binding SelectedMember}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="240">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamMemberRowViewModel">
|
||||
<Grid ColumnDefinitions="*,150,Auto" Margin="0,3">
|
||||
<StackPanel Grid.Column="0" Spacing="2">
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding Email}" />
|
||||
</StackPanel>
|
||||
<TextBlock Grid.Column="1" Classes="hint" FontSize="10" VerticalAlignment="Center"
|
||||
Text="{Binding KeyState}" TextWrapping="Wrap" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Role}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Margin="10,0,0,0" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" IsVisible="{Binding CanAdministerSelected}">
|
||||
<TextBox Grid.Column="0" PlaceholderText="colleague@example.com" Text="{Binding InviteEmail}"
|
||||
Margin="0,0,6,0" />
|
||||
<Button Grid.Column="1" Classes="accent" Content="ADD MEMBER"
|
||||
Command="{Binding AddMemberCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Grid.Column="2" Classes="danger" Content="REMOVE" Margin="6,0,0,0"
|
||||
Command="{Binding RemoveMemberCommand}" IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Removes the selected member and withdraws every vault key they hold from this team. It blocks future reads only — anything already on their machine stays there, so rotate the credentials that matter." />
|
||||
</Grid>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
IsVisible="{Binding CanAdministerSelected}"
|
||||
Text="Adding somebody lets the server serve them this team's vaults. It does not let them read one: a vault key can only be wrapped by a machine that already holds it, which is what SHARE KEY below does." />
|
||||
</StackPanel>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<!-- Vaults -->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="VAULTS" VerticalAlignment="Center" />
|
||||
<Button Grid.Column="1" Classes="ghost" Content="NEW VAULT"
|
||||
Command="{Binding CreateVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" IsVisible="{Binding CanAdministerSelected}" />
|
||||
</Grid>
|
||||
|
||||
<ListBox ItemsSource="{Binding Vaults}" SelectedItem="{Binding SelectedVault}"
|
||||
Background="Transparent" BorderThickness="0" MaxHeight="200">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TeamVaultRowViewModel">
|
||||
<StackPanel Spacing="2" Margin="0,3">
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" FontWeight="Medium"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="hint" FontSize="10" Text="{Binding State}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !HasSelection}"
|
||||
Text="Select a team to see its vaults." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SHARE KEY" Command="{Binding ShareVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Wraps the selected vault's key to the selected member. Their published key is checked against the server's append-only key log first, and nothing is wrapped if it does not appear there unchanged." />
|
||||
<Button Classes="danger" Content="WITHDRAW KEY" Command="{Binding RevokeVaultCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
ToolTip.Tip="Withdraws the selected member's key to the selected vault. Blocks future reads only." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="Sharing verifies the recipient's key against the key log, which proves this server has been consistent with itself — not that the key is the right person's. Compare the fingerprint with them over a channel this server does not carry before sharing anything that matters." />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<TextBlock Grid.Row="1" Classes="hint" FontSize="11" Margin="20" TextWrapping="Wrap"
|
||||
VerticalAlignment="Top" IsVisible="{Binding !HasSelection}"
|
||||
Text="Create a team on the left, or wait to be added to one. A team owns vaults; a vault's key is what makes its contents readable, and that key is handed out by people rather than by the server." />
|
||||
|
||||
<Border Grid.Row="2" Padding="14,10" BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="{Binding Status}" TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>Teams: who is in one, what they may do, and which vaults they hold a key to.</summary>
|
||||
internal sealed partial class TeamsScreen : UserControl
|
||||
{
|
||||
public TeamsScreen() => InitializeComponent();
|
||||
}
|
||||
@@ -5,17 +5,20 @@
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The tab strip above the terminal.
|
||||
The tab strip, above every screen.
|
||||
|
||||
Every tab is one pane in the one WebView, so switching is a single frame telling the page which pane to
|
||||
show — nothing is created, nothing is destroyed, and the shell behind a hidden pane goes on running and
|
||||
goes on producing output. That is what makes tabs cost almost nothing here, and it is also why closing
|
||||
one is the only thing in this application that deliberately ends a session.
|
||||
|
||||
Three of the design's header controls are absent: SPLIT, FORWARDS and SNIPPETS. Splits would need a
|
||||
second pane geometry the renderer does not have, port forwarding does not exist in the SSH layer, and
|
||||
there is no snippet item type in the vault. Three disabled buttons would teach nobody anything; see
|
||||
docs/design-import-gaps.md.
|
||||
It spans the whole window rather than the hosts screen, which is what the strip is for: a connection you
|
||||
opened stays visible and one click away while you are looking at a transfer, a key, or preferences.
|
||||
Clicking a tab switches the window's surface to that terminal — see MainWindowViewModel.ShellSurface.
|
||||
|
||||
Two of the design's header controls are still absent: SPLIT and FORWARDS. Splits would need a second
|
||||
pane geometry the renderer does not have, and port forwarding does not exist in the SSH layer. Two
|
||||
disabled buttons would teach nobody anything; see docs/design-import-gaps.md.
|
||||
|
||||
An ItemsControl of buttons rather than a TabStrip, because the selection lives on the shell — a tab
|
||||
outlives the vault that opened it — and a strip that owned its own selection would be a second copy of
|
||||
@@ -24,10 +27,15 @@
|
||||
|
||||
<Border Height="34" Background="{StaticResource Chrome}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
|
||||
<ScrollViewer Grid.Column="0" HorizontalScrollBarVisibility="Auto"
|
||||
VerticalScrollBarVisibility="Disabled">
|
||||
<!--
|
||||
Everything in one scrolling row: the tabs, then the button that opens another, then the sentence for
|
||||
when there are none. The strip stays rather than collapsing — a row of chrome that appears and
|
||||
disappears would move every screen up and down by 34 pixels each time the last tab closed.
|
||||
-->
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Tabs}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -36,57 +44,85 @@
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TerminalTabViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
|
||||
<Button Grid.Column="0" Classes="flat tab"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
Classes.active="{Binding IsSelected}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green while the shell behind this tab is running, grey once it has ended. The pane
|
||||
keeps its scrollback either way, which is usually why somebody is still looking at a
|
||||
tab whose dot has gone out.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!--
|
||||
The close box is inside the tab, not beside it. Beside it, the two were siblings in a grid:
|
||||
the cross was as tall as the strip and sat outside the tab's own background, so it read as a
|
||||
divider between tabs rather than as part of one, and the tab it belonged to was ambiguous
|
||||
for the tab to its right.
|
||||
|
||||
<Button Grid.Column="1" Classes="flat close" Width="20"
|
||||
VerticalAlignment="Stretch"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Closes this terminal and ends its shell.">
|
||||
<TextBlock Text="✕" FontSize="10" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
Nested buttons work, and it is worth knowing why rather than assuming. Avalonia's
|
||||
Button.OnPointerPressed checks IsLeftButtonPressed, takes the pointer capture and marks the
|
||||
event handled — so a left press on the cross does not also select the tab. It deliberately
|
||||
does not handle any other button, which is exactly what lets a middle press bubble out of
|
||||
the cross and reach the handler below.
|
||||
-->
|
||||
<Button Classes="flat tab"
|
||||
Classes.active="{Binding IsSelected}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
PointerPressed="OnTabPointerPressed"
|
||||
ToolTip.Tip="{Binding Address}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||
<!--
|
||||
Green while the shell behind this tab is running, grey once it has ended. The pane
|
||||
keeps its scrollback either way, which is usually why somebody is still looking at a
|
||||
tab whose dot has gone out.
|
||||
-->
|
||||
<Ellipse Classes="dot" Width="5" Height="5" Classes.live="{Binding IsLive}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
|
||||
<!--
|
||||
Always drawn, never on hover only. The strip has no other close affordance, and one
|
||||
that appears when the pointer is already over the tab cannot be found by somebody
|
||||
looking for it.
|
||||
-->
|
||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:MainWindowViewModel)DataContext).CloseTabCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="Closes this terminal and ends its shell. Middle-click the tab does the same.">
|
||||
<TextBlock Text="✕" FontSize="9" HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
<!--
|
||||
Nothing open, and this is where that is said. The strip stays rather than collapsing — a row of
|
||||
chrome that appears and disappears moves the terminal up and down by 34 pixels every time the last
|
||||
tab closes — and it is also the only place near the terminal that can carry a sentence at all: the
|
||||
rectangle below is a native child window, and anything Avalonia draws in it is drawn underneath.
|
||||
-->
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5"
|
||||
Text="no terminals open · choose a host and press Connect, or Ctrl+K"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding !HasTabs}" />
|
||||
<!--
|
||||
Opens the quick-connect palette, which is also what Ctrl+K does — so the tooltip can say that
|
||||
honestly, and there is one way to start a connection rather than two that have to agree.
|
||||
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding SelectedTab.Address}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis" MaxWidth="280"
|
||||
IsVisible="{Binding HasTabs}" />
|
||||
Not a MenuFlyout offering "SSH" and "local shell", which is the nicer-looking answer and is not
|
||||
verifiably safe here: this strip sits directly above the WebView's rectangle, and whether a popup
|
||||
dropping into it composites above a native child window depends on whether Avalonia gives it its
|
||||
own platform window. docs/platform-flags.md records what this project already paid for treating a
|
||||
rendering claim as settled without a screenshot. The palette has no such question — opening it
|
||||
collapses the terminal outright.
|
||||
-->
|
||||
<Button Classes="flat tab plus" Width="30"
|
||||
Command="{Binding ToggleSearchCommand}"
|
||||
ToolTip.Tip="Open a connection · Ctrl+K">
|
||||
<TextBlock Text="+" FontSize="14" HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
<!--
|
||||
Nothing open, and this is where that is said. It is also the only place near the terminal that can
|
||||
carry a sentence at all: the rectangle below is a native child window, and anything Avalonia draws
|
||||
in it is drawn underneath.
|
||||
-->
|
||||
<TextBlock Classes="mono" FontSize="9.5"
|
||||
Text="no terminals open · press + or Ctrl+K, or choose a host and press Connect"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Margin="12,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding !HasTabs}" />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -1,9 +1,56 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The tab strip above the terminal.</summary>
|
||||
/// <summary>The tab strip, above every screen.</summary>
|
||||
internal sealed partial class TerminalTabs : UserControl
|
||||
{
|
||||
public TerminalTabs() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Closes a tab on a middle click.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Wired on the tab's own template root, which is the whole answer to "and not on the strip itself".
|
||||
/// A middle press on the background, on the sentence, or on the button that opens a connection reaches
|
||||
/// no handler at all, because there is none there to reach. Nothing has to test what was clicked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><c>PointerUpdateKind</c>, not <c>IsMiddleButtonPressed</c>.</b> The latter reports button
|
||||
/// <em>state</em>: it is equally true for a left press made while the middle button happens to be held,
|
||||
/// and for every press during a middle drag. The question here is which button caused this press, and
|
||||
/// that is the one thing only <c>PointerUpdateKind</c> answers.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// On press rather than on release, which is what every browser and every terminal does. Matching a
|
||||
/// release to its press would need capture tracking, to buy the ability to change your mind about a
|
||||
/// middle click — a gesture nobody makes by accident and nobody aborts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnTabPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (sender is not Visual { DataContext: TerminalTabViewModel tab }
|
||||
|| DataContext is not MainWindowViewModel shell)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.GetCurrentPoint((Visual)sender).Properties.PointerUpdateKind
|
||||
is not PointerUpdateKind.MiddleButtonPressed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Handled, so the strip's ScrollViewer does not also take this as the start of a pan.
|
||||
e.Handled = true;
|
||||
|
||||
// Fire-and-forget, as the host sidebar's double-tap connect is: CloseTabCommand is asynchronous —
|
||||
// it waits for the workspace to tear the session down — and an event handler has nowhere to await
|
||||
// it. Its failures are the workspace's to report, not this strip's.
|
||||
shell.CloseTabCommand.Execute(tab);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,15 +65,32 @@
|
||||
|
||||
<!-- ============ The host, and the connection ============ -->
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="Auto,Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
|
||||
<!--
|
||||
Which sort of remote. Two buttons rather than one picker holding hosts and buckets together, and
|
||||
the reason is that the two are not interchangeable: a host brings a password box, a host key
|
||||
prompt and a mismatch refusal with it, and a bucket has no equivalent of any of them. One picker
|
||||
would mean half this bar appearing and disappearing with the selection.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="2" Margin="0,0,8,0"
|
||||
IsVisible="{Binding !IsConnected}">
|
||||
<Button Classes="flat cat" Content="HOST" Classes.active="{Binding ShowsHostPicker}"
|
||||
Command="{Binding ShowRemoteCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Host}" />
|
||||
<Button Classes="flat cat" Content="BUCKET" Classes.active="{Binding ShowsBucketPicker}"
|
||||
Command="{Binding ShowRemoteCommand}"
|
||||
CommandParameter="{x:Static vm:RemoteKind.Bucket}" />
|
||||
</StackPanel>
|
||||
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
IsVisible="{Binding ShowsHostPicker}"
|
||||
PlaceholderText="choose a host">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
@@ -87,26 +104,43 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<ComboBox Grid.Column="2" ItemsSource="{Binding Buckets}"
|
||||
SelectedItem="{Binding SelectedBucket}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
IsVisible="{Binding ShowsBucketPicker}"
|
||||
PlaceholderText="choose a bucket">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ObjectStoreRowViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Description}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
|
||||
holding a different value. This connection authenticates separately, so a password typed to open a
|
||||
terminal was never offered here.
|
||||
-->
|
||||
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
<TextBox Grid.Column="3" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
Text="{Binding TypedPassword}" PlaceholderText="password"
|
||||
IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
IsEnabled="{Binding !IsConnected}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
|
||||
<Button Grid.Column="4" Classes="accent" Content="{Binding ConnectLabel}" Margin="6,0,0,0"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsVisible="{Binding !IsConnected}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
<Button Grid.Column="4" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding DisconnectCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
|
||||
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
|
||||
<Border Grid.Column="5" Classes="chip accent" Margin="8,0,0,0"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Text="{Binding ConnectedTo}" />
|
||||
</Border>
|
||||
@@ -122,7 +156,12 @@
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
|
||||
|
||||
<!-- ==== This machine ==== -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
<!--
|
||||
AllowDrop on the pane rather than on the list, because an empty directory lays its ListBox out at
|
||||
zero height behind the empty-state sentence — a handler on the list would have nothing to hit.
|
||||
This side takes remote rows only; see TransfersScreen.axaml.cs.
|
||||
-->
|
||||
<Grid Grid.Column="0" x:Name="LocalPane" RowDefinitions="Auto,Auto,Auto,*" DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -204,6 +243,17 @@
|
||||
Text="Nothing in this folder. Use the trail above to go somewhere else."
|
||||
IsVisible="{Binding !HasLocalEntries}" />
|
||||
|
||||
<!--
|
||||
The drop highlight, over the whole pane and last so it is on top.
|
||||
|
||||
IsHitTestVisible="False" is not optional. An overlay that takes part in hit testing swallows the
|
||||
DragOver events underneath it the moment it appears — so the pointer leaves, the highlight never
|
||||
clears, and the drop lands nowhere.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="4" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsLocalDropTarget}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ==== The two directions ==== -->
|
||||
@@ -226,7 +276,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
<Grid Grid.Column="2" x:Name="RemotePane" RowDefinitions="Auto,Auto,Auto,Auto,*"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -345,6 +396,24 @@
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Two highlights rather than one, because refusing is worth showing. Something dragged over a
|
||||
disconnected pane has to say so under the pointer — a pane that lights up nowhere reads as a
|
||||
window that has stopped answering, and the answer arriving after the drop is the answer arriving
|
||||
too late. See the local pane for why neither may hit-test.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
|
||||
Background="{StaticResource AccentWash}" BorderBrush="{StaticResource Accent}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropTarget}" />
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="5" IsHitTestVisible="False"
|
||||
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="2" IsVisible="{Binding IsRemoteDropRefused}">
|
||||
<TextBlock Classes="hint" Text="Connect to a host first." FontSize="11"
|
||||
Foreground="{StaticResource Danger}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
@@ -8,12 +11,42 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// The two-pane file browser and the transfer queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
|
||||
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
|
||||
/// <c>MainWindowViewModel.LockAsync</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything about drag and drop is in this file and nothing about it is policy.</b> The handlers pull
|
||||
/// paths or rows out of a drop and hand them to <c>QueueUploads</c>/<c>QueueDownloads</c>; what may be
|
||||
/// queued, what is skipped and what is said about it all live in the view model, where they can be tested
|
||||
/// without a window. Nothing headless can synthesise a real platform drag, so the wiring below is verified
|
||||
/// by hand — see <c>docs/manual-checks.md</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersScreen : UserControl
|
||||
{
|
||||
/// <summary>
|
||||
/// How remote rows travel while being dragged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An in-process format, so the rows themselves cross rather than a list of path strings that would
|
||||
/// have to be looked up again on the other side. It also cannot be confused with a drop from the
|
||||
/// operating system: a file dragged out of the file manager arrives as <c>DataFormat.File</c> and never
|
||||
/// as this, so "did this come from our own remote pane" needs no guessing.
|
||||
/// </remarks>
|
||||
private static readonly DataFormat<RemoteDragPayload> RemoteEntries =
|
||||
DataFormat.CreateInProcessFormat<RemoteDragPayload>("dodossh/remote-entries");
|
||||
|
||||
/// <summary>How far the pointer moves before a press becomes a drag.</summary>
|
||||
/// <remarks>
|
||||
/// Without a threshold every click on a row starts a drag, which makes selecting one impossible.
|
||||
/// </remarks>
|
||||
private const double DragThreshold = 4;
|
||||
|
||||
private PointerPressedEventArgs? pressed;
|
||||
private Point pressedAt;
|
||||
|
||||
public TransfersScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -23,11 +56,32 @@ internal sealed partial class TransfersScreen : UserControl
|
||||
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
|
||||
LocalList.DoubleTapped += OnLocalActivated;
|
||||
RemoteList.DoubleTapped += OnRemoteActivated;
|
||||
|
||||
// On the pane rather than on the list. A directory with nothing in it lays its ListBox out at zero
|
||||
// height behind the empty-state sentence, and a drop handler on the list would have nothing to hit.
|
||||
LocalPane.AddHandler(DragDrop.DragOverEvent, OnLocalDragOver);
|
||||
LocalPane.AddHandler(DragDrop.DragLeaveEvent, OnLocalDragLeave);
|
||||
LocalPane.AddHandler(DragDrop.DropEvent, OnLocalDrop);
|
||||
|
||||
RemotePane.AddHandler(DragDrop.DragOverEvent, OnRemoteDragOver);
|
||||
RemotePane.AddHandler(DragDrop.DragLeaveEvent, OnRemoteDragLeave);
|
||||
RemotePane.AddHandler(DragDrop.DropEvent, OnRemoteDrop);
|
||||
|
||||
// Tunnelling, so noting where a press started does not take the press away from the ListBox — a row
|
||||
// still selects, and the drag only begins once the pointer has moved far enough.
|
||||
foreach (var list in new Control[] { LocalList, RemoteList })
|
||||
{
|
||||
list.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
|
||||
list.AddHandler(PointerMovedEvent, OnPointerMoved, RoutingStrategies.Tunnel);
|
||||
list.AddHandler(PointerReleasedEvent, OnPointerReleased, RoutingStrategies.Tunnel);
|
||||
}
|
||||
}
|
||||
|
||||
private TransfersViewModel? Transfers => DataContext as TransfersViewModel;
|
||||
|
||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.OpenLocalCommand.Execute(null);
|
||||
}
|
||||
@@ -40,9 +94,211 @@ internal sealed partial class TransfersScreen : UserControl
|
||||
/// </remarks>
|
||||
private void OnRemoteActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Starting a drag ----
|
||||
|
||||
private void OnPointerPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (e.GetCurrentPoint(this).Properties.PointerUpdateKind is PointerUpdateKind.LeftButtonPressed)
|
||||
{
|
||||
pressed = e;
|
||||
pressedAt = e.GetPosition(this);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPointerReleased(object? sender, PointerReleasedEventArgs e) => pressed = null;
|
||||
|
||||
/// <remarks>
|
||||
/// The drag starts here rather than on the press, because a press is also how a row is selected.
|
||||
/// <c>DoDragDropAsync</c> wants the original <c>PointerPressedEventArgs</c>, so it is held from the
|
||||
/// press until either the pointer moves far enough or the button comes back up.
|
||||
/// </remarks>
|
||||
private void OnPointerMoved(object? sender, PointerEventArgs e)
|
||||
{
|
||||
if (pressed is not { } origin || Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed)
|
||||
{
|
||||
pressed = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var moved = e.GetPosition(this) - pressedAt;
|
||||
|
||||
if (Math.Abs(moved.X) < DragThreshold && Math.Abs(moved.Y) < DragThreshold)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pressed = null;
|
||||
|
||||
if (ReferenceEquals(sender, RemoteList))
|
||||
{
|
||||
StartRemoteDrag(origin, transfers);
|
||||
}
|
||||
else
|
||||
{
|
||||
_ = StartLocalDragAsync(origin, transfers);
|
||||
}
|
||||
}
|
||||
|
||||
private static void StartRemoteDrag(PointerPressedEventArgs origin, TransfersViewModel transfers)
|
||||
{
|
||||
if (transfers.SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var transfer = new DataTransfer();
|
||||
transfer.Add(DataTransferItem.Create(RemoteEntries, new RemoteDragPayload([row])));
|
||||
|
||||
_ = DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Local files travel as the platform's own file format rather than as an in-process one, which is what
|
||||
/// makes a single drag work both onto the remote pane and out into the file manager. It needs a real
|
||||
/// <see cref="IStorageItem"/>, hence the asynchronous lookup — and hence a fire-and-forget call, because
|
||||
/// nothing on a pointer-moved path can await.
|
||||
/// </remarks>
|
||||
private async Task StartLocalDragAsync(PointerPressedEventArgs origin, TransfersViewModel transfers)
|
||||
{
|
||||
if (transfers.SelectedLocalEntry is not { IsFile: true } row
|
||||
|| TopLevel.GetTopLevel(this) is not { } top)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var file = await top.StorageProvider.TryGetFileFromPathAsync(row.FullPath).ConfigureAwait(true);
|
||||
|
||||
if (file is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
using var transfer = new DataTransfer();
|
||||
transfer.Add(DataTransferItem.CreateFile(file));
|
||||
|
||||
await DragDrop.DoDragDropAsync(origin, transfer, DragDropEffects.Copy).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// ---- Accepting a drop ----
|
||||
|
||||
/// <remarks>
|
||||
/// The local pane takes remote rows and nothing else. A file dragged from the file manager onto it
|
||||
/// would be a copy from this machine to this machine, which is not what this screen is for.
|
||||
/// </remarks>
|
||||
private void OnLocalDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var accepted = e.DataTransfer.Contains(RemoteEntries);
|
||||
|
||||
e.DragEffects = accepted ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsLocalDropTarget = accepted;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnLocalDragLeave(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsLocalDropTarget = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLocalDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
transfers.IsLocalDropTarget = false;
|
||||
e.Handled = true;
|
||||
|
||||
if (e.DataTransfer.TryGetValue(RemoteEntries) is { } payload)
|
||||
{
|
||||
transfers.QueueDownloads(payload.Rows);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The remote pane takes files: from the file manager, and from the local pane, which offers the same
|
||||
/// platform format. A drop while disconnected is refused visibly rather than accepted and then
|
||||
/// explained, because a red pane under the pointer is the answer arriving before the drop rather than
|
||||
/// after it.
|
||||
/// </remarks>
|
||||
private void OnRemoteDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var files = e.DataTransfer.Contains(DataFormat.File);
|
||||
var connected = Transfers is { IsConnected: true };
|
||||
|
||||
e.DragEffects = files && connected ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsRemoteDropTarget = files && connected;
|
||||
transfers.IsRemoteDropRefused = files && !connected;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
private void OnRemoteDragLeave(object? sender, DragEventArgs e) => ClearRemoteHighlight();
|
||||
|
||||
private void OnRemoteDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (Transfers is not { } transfers)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ClearRemoteHighlight();
|
||||
e.Handled = true;
|
||||
|
||||
if (e.DataTransfer.TryGetFiles() is not { } files)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// TryGetLocalPath, because the queue reads bytes off a real path. A storage item that is not a
|
||||
// local file — one from a cloud provider's virtual folder — has none, and dropping it is a thing
|
||||
// this screen declines rather than a thing it half does.
|
||||
var paths = files
|
||||
.Select(file => file.TryGetLocalPath())
|
||||
.OfType<string>()
|
||||
.ToList();
|
||||
|
||||
transfers.QueueUploads(paths);
|
||||
}
|
||||
|
||||
private void ClearRemoteHighlight()
|
||||
{
|
||||
if (Transfers is { } transfers)
|
||||
{
|
||||
transfers.IsRemoteDropTarget = false;
|
||||
transfers.IsRemoteDropRefused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The remote rows carried by one drag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A record wrapping the list rather than the list itself, because <c>DataFormat.CreateInProcessFormat</c>
|
||||
/// keys on the type and a bare <c>IReadOnlyList<T></c> is too general a key to be sure of.
|
||||
/// </remarks>
|
||||
internal sealed record RemoteDragPayload(IReadOnlyList<RemoteEntryRowViewModel> Rows);
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Classes="heading" Text="Unlock your keychain" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
|
||||
<!--
|
||||
@@ -33,7 +33,7 @@
|
||||
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•">
|
||||
PlaceholderText="keychain passphrase" PasswordChar="•">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
@@ -52,7 +52,7 @@
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||
@@ -73,7 +73,7 @@
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
Text="Locking closes the keychain, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the keychain, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -88,7 +88,7 @@
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." />
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the keychain is on the server and comes back." />
|
||||
<Button Classes="ghost" Content="RESET THIS MACHINE"
|
||||
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
@@ -2,25 +2,30 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
xmlns:ssh="using:DodoSSH.Client.Ssh"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
Everything in the vault that is not a host: the keys, the stored passwords, and the host keys this user
|
||||
has approved.
|
||||
The keychain: the SSH keys and the stored passwords. Things a person creates and edits.
|
||||
|
||||
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
|
||||
shape for every kind, which is what makes the ALL category possible and is why the row projection
|
||||
exists; see VaultItemRowViewModel.
|
||||
|
||||
Two of the design's five categories are not here. IDENTITIES and CERTIFICATES have no item type behind
|
||||
them — the vault holds exactly four kinds and two of those are hosts and pins — so listing them would be
|
||||
two headings that could never have anything under them. HOST KEYS is the other way round: a real,
|
||||
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md.
|
||||
HOST KEYS was a fourth category here and is now a screen of its own; see KnownHostsScreen. It never fit:
|
||||
the two categories left are things somebody made on purpose, and a pin is a decision recorded at the
|
||||
moment of connecting — nobody goes looking for one in a list of credentials. It also has a workflow the
|
||||
shared table could not serve, which is comparing an untruncated fingerprint against a published one.
|
||||
|
||||
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The
|
||||
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its
|
||||
access service, so a rail with three entries would be showing two vaults nothing can open.
|
||||
Two of the design's five categories are still not here. IDENTITIES and CERTIFICATES have no item type
|
||||
behind them, so listing them would be two headings that could never have anything under them. Recorded
|
||||
in docs/design-import-gaps.md.
|
||||
|
||||
The SCOPES rail below the categories is the keychain list. Since M3 it genuinely has more than one entry
|
||||
when somebody is in a team — but it is still not a selector, because every table on this screen already
|
||||
spans every keychain this session holds a key for and each row names its own. What it carries instead is
|
||||
the one keychain question with an answer: where a new item is filed.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="176,*,244">
|
||||
@@ -31,7 +36,7 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Margin="0,12">
|
||||
|
||||
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" />
|
||||
<TextBlock Classes="label" Text="KEYCHAIN" Margin="14,0,14,8" />
|
||||
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.All}"
|
||||
@@ -66,13 +71,18 @@
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!--
|
||||
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a bucket
|
||||
is something somebody creates, edits and keeps a secret for, which is what the other two
|
||||
categories are. A pin is a decision recorded at connect time and is not.
|
||||
-->
|
||||
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
|
||||
CommandParameter="{x:Static vm:VaultSection.KnownHosts}"
|
||||
Classes.active="{Binding ShowsKnownHosts}">
|
||||
CommandParameter="{x:Static vm:VaultSection.Buckets}"
|
||||
Classes.active="{Binding ShowsBuckets}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Border Grid.Column="0" Classes="rowmark catmark" />
|
||||
<TextBlock Grid.Column="1" Text="HOST KEYS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding KnownHostPins.Count}"
|
||||
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding ObjectStores.Count}"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</Grid>
|
||||
</Button>
|
||||
@@ -82,18 +92,35 @@
|
||||
<TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" />
|
||||
|
||||
<!--
|
||||
One entry per vault this session opened. Not a selector: every list on this screen reads the
|
||||
active vault, and a rail that let you click a vault you cannot switch to would be a control that
|
||||
does nothing. It is here because knowing which vault you are looking at is worth a line, and
|
||||
because this is where a second one appears when shared vaults arrive.
|
||||
Still not a selector. Every list on this screen now spans every vault this session holds a key
|
||||
for, and each row names its own vault — so there is nothing to switch to. What the picker below
|
||||
chooses is where a *new* item is filed, which is a different question and the only one that has
|
||||
an answer worth asking for.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
|
||||
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10"
|
||||
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
|
||||
Text="One vault, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
|
||||
|
||||
<!--
|
||||
Hidden at one vault, which is where most people stay. A control offering a single option is a
|
||||
question with no answer.
|
||||
-->
|
||||
<StackPanel Margin="14,10,14,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
|
||||
<TextBlock Classes="label" Text="NEW ITEMS GO TO" />
|
||||
<ComboBox ItemsSource="{Binding TargetVaults}"
|
||||
SelectedItem="{Binding SelectedTargetVault}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
|
||||
<TextBlock Text="{Binding Display}" FontSize="11" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="An item filed into a team's vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own." />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Items that would not decrypt. Shown here rather than only in the status line because this is the
|
||||
@@ -121,10 +148,18 @@
|
||||
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
Always offered. Every category left on this screen is one things can be added to — the one
|
||||
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
|
||||
either. See KnownHostsScreen.
|
||||
-->
|
||||
<Button Classes="ghost" Content="GENERATE KEY" Command="{Binding NewGeneratedKeyCommand}"
|
||||
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
|
||||
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}"
|
||||
IsVisible="{Binding CanAddToSection}" />
|
||||
ToolTip.Tip="Pastes in a key you already have." />
|
||||
<Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
|
||||
<Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
|
||||
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -218,7 +253,7 @@
|
||||
empty rows, this says what is missing in one line.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
|
||||
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||
Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||
IsVisible="{Binding ShowsItemActions}">
|
||||
@@ -226,6 +261,17 @@
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The public half only, and there is no button for the other one. Installing a key means pasting
|
||||
this line into a host's authorized_keys; a private key on the clipboard is a private key in
|
||||
every application on the machine.
|
||||
-->
|
||||
<Button Classes="ghost" Content="COPY PUBLIC KEY" Margin="0,6,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
IsVisible="{Binding SelectedItemIsKey}"
|
||||
Command="{Binding CopyPublicKeyCommand}"
|
||||
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
|
||||
|
||||
<!--
|
||||
The question DELETE asks, in the place those two buttons were. Here rather than over the
|
||||
screen, because this pane is where the item being deleted is described: the name, the kind and
|
||||
@@ -238,19 +284,50 @@
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm, a
|
||||
comment, and a button. What it produces lands in the editor below, unsaved — so there is still
|
||||
exactly one thing on this screen that writes a key, and it is still SAVE.
|
||||
-->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
|
||||
<TextBlock Classes="label" Text="NEW SSH KEY" Margin="0,0,0,4" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
Buttons and a command rather than a selector bound to the algorithm, which is the same
|
||||
choice the category rail makes and for the same reason: a selector moves its own highlight
|
||||
before anything can refuse, so it can end up showing a choice nobody made.
|
||||
-->
|
||||
<Button Classes="flat choice" Content="ED25519"
|
||||
Classes.active="{Binding GeneratesEd25519}"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}"
|
||||
ToolTip.Tip="What every current OpenSSH prefers. Small, fast, and generated instantly." />
|
||||
<Button Classes="flat choice" Content="RSA 4096"
|
||||
Classes.active="{Binding GeneratesRsa}"
|
||||
Command="{Binding ChooseKeyAlgorithmCommand}"
|
||||
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}"
|
||||
ToolTip.Tip="For servers too old to accept Ed25519. Larger, and a few seconds to generate." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
|
||||
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
|
||||
|
||||
<!--
|
||||
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
||||
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
||||
the one place it can be checked against what the operator published. What it does have is a
|
||||
way out, because a changed host key is refused outright and a rebuilt server would otherwise
|
||||
be unreachable for ever.
|
||||
Said plainly rather than left to be discovered. Writing an encrypted openssh-key-v1 file needs
|
||||
bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
|
||||
already makes: a passphrase protects a key file on a disk, and this key is never on one.
|
||||
-->
|
||||
<StackPanel Spacing="6" Margin="0,14,0,0" IsVisible="{Binding SelectedItemIsPin}">
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
|
||||
<Button Classes="danger" Content="FORGET THIS HOST KEY" HorizontalAlignment="Left"
|
||||
Command="{Binding ForgetPinCommand}"
|
||||
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
|
||||
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
|
||||
<Button Classes="accent" Content="GENERATE" Command="{Binding GenerateKeyCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGenerateKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -273,7 +350,7 @@
|
||||
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
|
||||
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
|
||||
@@ -307,6 +384,43 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- The bucket editor. -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
|
||||
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
|
||||
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
|
||||
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
|
||||
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
|
||||
<!--
|
||||
Masked, like a password and for the same reason: a secret access key is one. The access key id
|
||||
beside it is an identifier and is shown, which is also why the two are separate boxes.
|
||||
-->
|
||||
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
|
||||
PasswordChar="•" />
|
||||
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
|
||||
<!--
|
||||
Blank means Amazon, and then the region resolves the host. Anything else is a full URL, which
|
||||
is what makes this work against a self-hosted service.
|
||||
-->
|
||||
<TextBox Text="{Binding BucketEditorEndpoint}"
|
||||
PlaceholderText="endpoint (blank: Amazon S3)" />
|
||||
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}"
|
||||
Content="Address the bucket as a path" />
|
||||
<!--
|
||||
Said where the decision is made. Getting this wrong produces a DNS failure whose message
|
||||
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
|
||||
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
||||
Height="44" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="9.5"
|
||||
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelObjectStoreEditCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
|
||||
@@ -349,6 +349,21 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.import": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.objectstore": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, )",
|
||||
"AWSSDK.S3": "[4.0.101.6, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -357,7 +372,8 @@
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.shell": {
|
||||
@@ -365,6 +381,8 @@
|
||||
"dependencies": {
|
||||
"Avalonia": "[12.1.1, )",
|
||||
"CommunityToolkit.Mvvm": "[8.4.2, )",
|
||||
"DodoSSH.Client.Import": "[1.0.0, )",
|
||||
"DodoSSH.Client.ObjectStore": "[1.0.0, )",
|
||||
"DodoSSH.Client.Session": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )",
|
||||
@@ -374,6 +392,7 @@
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -417,6 +436,21 @@
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>What was done to an item.</summary>
|
||||
public enum ActivityOperation
|
||||
{
|
||||
/// <summary>It was created.</summary>
|
||||
Created = 0,
|
||||
|
||||
/// <summary>It was changed.</summary>
|
||||
Updated = 1,
|
||||
|
||||
/// <summary>It was deleted.</summary>
|
||||
Deleted = 2,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One create, edit or delete of a keychain item, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><see cref="ChangedFields"/> holds names and never values.</b> That is the rule the whole type is built
|
||||
/// around, and it is the same one ADR 0006 imposes on the server's own <c>detail</c> column: an audit log
|
||||
/// that recorded what a password used to be would be a plaintext credential store with a vault drawn around
|
||||
/// it. "Password" is what somebody needs to see; the old password is what nobody does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="ItemLabel"/> is a copy, taken at the time.</b> Deleting the item is one of the three things
|
||||
/// this records, so a lookup would resolve to nothing in exactly the case the entry matters most. It is also
|
||||
/// what makes a rename readable — an entry saying "renamed 'old-db'" is useful, and one saying "renamed
|
||||
/// 'prod-db'" because that is what it is called now is not.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record ActivityLogSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What kind of item this was about, as the sync contract names it.</summary>
|
||||
/// <remarks>
|
||||
/// Stored as the wire enum's name rather than its number, so an entry written by a build that knows a
|
||||
/// kind this one does not still reads as something — an unknown name is shown as itself, where an
|
||||
/// unknown number would have to be shown as a number.
|
||||
/// </remarks>
|
||||
public required string ItemKind { get; init; }
|
||||
|
||||
/// <summary>The item, so an entry can be traced to what it was about.</summary>
|
||||
public required Guid ItemId { get; init; }
|
||||
|
||||
/// <summary>What the item was called at the time.</summary>
|
||||
public required string ItemLabel { get; init; }
|
||||
|
||||
/// <summary>What was done.</summary>
|
||||
public ActivityOperation Operation { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The names of the fields that changed, separated by <c>", "</c>. Never their values.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One string rather than a collection, and the choice is about equality. A plain
|
||||
/// <see cref="IReadOnlyList{T}"/> on a record gets reference equality from the compiler-generated
|
||||
/// <c>Equals</c>, which is the trap <see cref="JumpChain"/> exists to avoid — and a second type of that
|
||||
/// shape is a lot of machinery for a value that is written once and only ever displayed. The separator is
|
||||
/// unambiguous because these are C# property names, which cannot contain one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Empty for a create and for a delete, where "which fields" has no meaning — every field arrived, or all
|
||||
/// of them went. Empty is also the honest answer when an update's before and after could not be compared,
|
||||
/// which is why nothing reading this may take empty to mean "nothing changed".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string ChangedFields { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>When it happened.</summary>
|
||||
public required DateTimeOffset At { get; init; }
|
||||
|
||||
/// <summary>Which machine it was done from, as that machine calls itself.</summary>
|
||||
public required string DeviceName { get; init; }
|
||||
|
||||
/// <summary>Which account in this organisation did it.</summary>
|
||||
public Guid ActorUserId { get; init; }
|
||||
|
||||
/// <summary>What this entry is called, derived from what it records.</summary>
|
||||
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
|
||||
public string Label => $"{Operation} {ItemLabel}";
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(ItemKind))
|
||||
{
|
||||
reason = "An activity log entry needs the kind of item it was about.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ItemId == Guid.Empty)
|
||||
{
|
||||
reason = "An activity log entry needs the item it was about.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(DeviceName))
|
||||
{
|
||||
reason = "An activity log entry needs the machine it was done from.";
|
||||
return false;
|
||||
}
|
||||
|
||||
// The label is deliberately not checked. An item somebody created and never named has an empty one,
|
||||
// and refusing to record that would mean the log's completeness depended on the user's tidiness.
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded activity log payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Entry">The entry.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record ActivityLogSecretDocument(ActivityLogSecret Entry, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionLogSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > ActivityLogSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside an activity log entry's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ActivityLogSecret.ItemKind"/> travels as its name and not its number, which is the one thing
|
||||
/// here worth deciding on purpose: item kinds are an open set, so a build that has not heard of the fifth one
|
||||
/// can still show "PortForward" where a number would leave it showing "9".
|
||||
/// </remarks>
|
||||
public static class ActivityLogSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
|
||||
public static byte[] Encode(ActivityLogSecret entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!entry.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(entry));
|
||||
}
|
||||
|
||||
var document = new ActivityLogPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
ItemKind = entry.ItemKind,
|
||||
ItemId = entry.ItemId,
|
||||
ItemLabel = entry.ItemLabel,
|
||||
Operation = (int)entry.Operation,
|
||||
ChangedFields = entry.ChangedFields.Length == 0 ? null : entry.ChangedFields,
|
||||
At = entry.At,
|
||||
DeviceName = entry.DeviceName,
|
||||
ActorUserId = entry.ActorUserId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out ActivityLogSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
ActivityLogPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, ActivityLogPayloadJsonContext.Default.ActivityLogPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new ActivityLogSecret
|
||||
{
|
||||
ItemKind = parsed.ItemKind ?? string.Empty,
|
||||
ItemId = parsed.ItemId,
|
||||
ItemLabel = parsed.ItemLabel ?? string.Empty,
|
||||
Operation = Enum.IsDefined((ActivityOperation)parsed.Operation)
|
||||
? (ActivityOperation)parsed.Operation
|
||||
: default,
|
||||
ChangedFields = parsed.ChangedFields ?? string.Empty,
|
||||
At = parsed.At,
|
||||
DeviceName = parsed.DeviceName ?? string.Empty,
|
||||
ActorUserId = parsed.ActorUserId,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new ActivityLogSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class ActivityLogPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? ItemKind { get; set; }
|
||||
|
||||
public Guid ItemId { get; set; }
|
||||
|
||||
public string? ItemLabel { get; set; }
|
||||
|
||||
public int Operation { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Written as null when empty rather than as <c>""</c>, so that a create and a delete — which have no
|
||||
/// changed fields by definition — omit the property entirely instead of carrying an empty one.
|
||||
/// </remarks>
|
||||
public string? ChangedFields { get; set; }
|
||||
|
||||
public DateTimeOffset At { get; set; }
|
||||
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
public Guid ActorUserId { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(ActivityLogPayloadDocument))]
|
||||
internal sealed partial class ActivityLogPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,147 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>How a connection ended.</summary>
|
||||
public enum ConnectionOutcome
|
||||
{
|
||||
/// <summary>The session ran and then ended — by the user, by the remote, or by the process closing.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
Closed = 0,
|
||||
|
||||
/// <summary>The connection was attempted and did not open.</summary>
|
||||
Failed = 1,
|
||||
|
||||
/// <summary>The host key was not the pinned one, so the client refused before authenticating.</summary>
|
||||
/// <remarks>
|
||||
/// Its own outcome rather than a kind of <see cref="Failed"/>, because it is the only one that means
|
||||
/// something about the <em>host</em> 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.
|
||||
/// </remarks>
|
||||
Refused = 2,
|
||||
}
|
||||
|
||||
/// <summary>What kind of session a log entry is about.</summary>
|
||||
public enum ConnectionKind
|
||||
{
|
||||
/// <summary>An interactive terminal.</summary>
|
||||
Terminal = 0,
|
||||
|
||||
/// <summary>An SFTP session for moving files.</summary>
|
||||
/// <remarks>
|
||||
/// Recorded separately and not hidden. Opening the file browser is a second login as far as the remote's
|
||||
/// own <c>auth.log</c> 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.
|
||||
/// </remarks>
|
||||
Sftp = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One connection that was made, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What is here, and what deliberately is not.</b> 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Written once, at close.</b> 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record ConnectionLogSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the host was called at the time, or a plain address when nothing named it.</summary>
|
||||
/// <remarks>
|
||||
/// A copy rather than a lookup through <see cref="HostId"/>, 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.
|
||||
/// </remarks>
|
||||
public required string HostLabel { get; init; }
|
||||
|
||||
/// <summary>The address as dialled, <c>user@host:port</c> style, or whatever was typed.</summary>
|
||||
public required string Address { get; init; }
|
||||
|
||||
/// <summary>The host item this was, or null when the connection did not come from one.</summary>
|
||||
public Guid? HostId { get; init; }
|
||||
|
||||
/// <summary>Whether this was a terminal or a file-transfer session.</summary>
|
||||
public ConnectionKind Kind { get; init; }
|
||||
|
||||
/// <summary>When it started.</summary>
|
||||
public required DateTimeOffset StartedAt { get; init; }
|
||||
|
||||
/// <summary>How long it lasted.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public TimeSpan Duration { get; init; }
|
||||
|
||||
/// <summary>How it ended.</summary>
|
||||
public ConnectionOutcome Outcome { get; init; }
|
||||
|
||||
/// <summary>Which machine it was made from, as that machine calls itself.</summary>
|
||||
public required string DeviceName { get; init; }
|
||||
|
||||
/// <summary>Which account in this organisation made it.</summary>
|
||||
public Guid ActorUserId { get; init; }
|
||||
|
||||
/// <summary>What this entry is called, derived from what it records.</summary>
|
||||
/// <inheritdoc cref="KnownHostSecret.Label" path="/remarks" />
|
||||
public string Label => string.Create(CultureInfo.InvariantCulture, $"{HostLabel} ({Address})");
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded connection log payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Entry">The entry.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record ConnectionLogSecretDocument(ConnectionLogSecret Entry, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
/// <remarks>
|
||||
/// Answered for consistency and never acted on: nothing edits a log entry, so there is no re-encode that
|
||||
/// could drop a newer client's field. It stays because the reconciler asks every kind.
|
||||
/// </remarks>
|
||||
public bool IsReadOnly => SchemaVersion > ConnectionLogSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a connection log entry's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Mirrors <see cref="KnownHostSecretCodec"/>. The two enums are written as numbers rather than names,
|
||||
/// unlike <see cref="ActivityLogSecret.ItemKind"/>: they are closed sets this codec owns, where the item kind
|
||||
/// is an open one that a newer build may extend.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// An unknown enum value decodes to the default rather than failing the whole entry. A log written by a
|
||||
/// newer client that has learned a fourth outcome is still worth showing with its host, its times and its
|
||||
/// actor intact — refusing it would lose the entry to save the one field nobody could have acted on anyway.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ConnectionLogSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises an entry to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The entry is not valid for storage.</exception>
|
||||
public static byte[] Encode(ConnectionLogSecret entry)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
|
||||
if (!entry.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(entry));
|
||||
}
|
||||
|
||||
var document = new ConnectionLogPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
HostLabel = entry.HostLabel,
|
||||
Address = entry.Address,
|
||||
HostId = entry.HostId,
|
||||
Kind = (int)entry.Kind,
|
||||
StartedAt = entry.StartedAt,
|
||||
DurationMs = (long)entry.Duration.TotalMilliseconds,
|
||||
Outcome = (int)entry.Outcome,
|
||||
DeviceName = entry.DeviceName,
|
||||
ActorUserId = entry.ActorUserId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out ConnectionLogSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
ConnectionLogPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, ConnectionLogPayloadJsonContext.Default.ConnectionLogPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = parsed.HostLabel ?? string.Empty,
|
||||
Address = parsed.Address ?? string.Empty,
|
||||
HostId = parsed.HostId,
|
||||
Kind = Enum.IsDefined((ConnectionKind)parsed.Kind) ? (ConnectionKind)parsed.Kind : default,
|
||||
StartedAt = parsed.StartedAt,
|
||||
Duration = TimeSpan.FromMilliseconds(parsed.DurationMs),
|
||||
Outcome = Enum.IsDefined((ConnectionOutcome)parsed.Outcome)
|
||||
? (ConnectionOutcome)parsed.Outcome
|
||||
: default,
|
||||
DeviceName = parsed.DeviceName ?? string.Empty,
|
||||
ActorUserId = parsed.ActorUserId,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new ConnectionLogSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class ConnectionLogPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? HostLabel { get; set; }
|
||||
|
||||
public string? Address { get; set; }
|
||||
|
||||
public Guid? HostId { get; set; }
|
||||
|
||||
public int Kind { get; set; }
|
||||
|
||||
public DateTimeOffset StartedAt { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Milliseconds as an integer rather than a <see cref="TimeSpan"/>, which <c>System.Text.Json</c> writes
|
||||
/// as <c>"00:11:03.4560000"</c> — a format whose parsing varies between platforms and whose precision
|
||||
/// invites a round-trip that is nearly but not exactly the value written.
|
||||
/// </remarks>
|
||||
public long DurationMs { get; set; }
|
||||
|
||||
public int Outcome { get; set; }
|
||||
|
||||
public string? DeviceName { get; set; }
|
||||
|
||||
public Guid ActorUserId { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(ConnectionLogPayloadDocument))]
|
||||
internal sealed partial class ConnectionLogPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,48 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A folder hosts can be filed under, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One field, which makes this the smallest secret in the vault, and the small size is the feature. A group
|
||||
/// is a heading in a sidebar; everything else somebody might want from it — which hosts are in it, where it
|
||||
/// sits in a tree, what colour it is — was considered and left out, each for its own reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No member list.</b> Membership is a <see cref="HostSecret.GroupId"/> on each host, so filing two
|
||||
/// different hosts into one group on two machines is two writes to two items. Held here it would be two
|
||||
/// writes to one item, and <see cref="ThreeWayMerge"/> has no set merge — the collision would resolve by one
|
||||
/// side winning outright and the other host silently leaving the group it was just put in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>No parent.</b> Groups are flat. Two clients can each re-parent A under B and B under A while offline,
|
||||
/// and a scalar merge accepts both: the result is a cycle that no reader can draw and that the server cannot
|
||||
/// even see, because it is inside the payload. One level of nesting is not worth a state with no repair path.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record HostGroupSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the group is called. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// A blank name is refused rather than defaulted. A group is only ever a heading, so a nameless one is
|
||||
/// indistinguishable from the ungrouped heading it would sit next to — and a user cannot select what they
|
||||
/// cannot tell apart.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A group needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded group payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Group">The group.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record HostGroupSecretDocument(HostGroupSecret Group, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > HostGroupSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a group item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="KnownHostSecretCodec"/>, for the same reasons and with the same guarantees. One field
|
||||
/// makes this look like ceremony around a string, and it is not: what the JSON envelope buys is a schema
|
||||
/// version, which is what lets a later build add a field without every older client silently dropping it on
|
||||
/// the next edit. See <see cref="HostSecretDocument.IsReadOnly"/>.
|
||||
/// </remarks>
|
||||
public static class HostGroupSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a group to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The group is not valid for storage.</exception>
|
||||
public static byte[] Encode(HostGroupSecret group)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(group);
|
||||
|
||||
if (!group.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(group));
|
||||
}
|
||||
|
||||
var document = new HostGroupPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = group.Label,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out HostGroupSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
HostGroupPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, HostGroupPayloadJsonContext.Default.HostGroupPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new HostGroupSecret { Label = parsed.Label ?? string.Empty };
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new HostGroupSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class HostGroupPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(HostGroupPayloadDocument))]
|
||||
internal sealed partial class HostGroupPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,63 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged group, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The group to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record HostGroupMergeResult(
|
||||
HostGroupSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a group against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One scalar, so this is the simplest merge in the client and the only interesting thing about it is what it
|
||||
/// does <em>not</em> have to consider. Filing a host into a group does not write to the group, so two people
|
||||
/// organising the same vault at the same time never collide here — the only way to reach this code is for two
|
||||
/// people to rename the same group differently, which is a real disagreement and gets a conflict notice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is redacted. A group name is the one thing a group has, and a notice saying only that "the name
|
||||
/// differed" would leave the user unable to tell which of their two names survived.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class HostGroupSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged group.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static HostGroupMergeResult Merge(
|
||||
HostGroupSecret ancestor,
|
||||
HostGroupSecret local,
|
||||
HostGroupSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merge = ThreeWayMerge.Scalar(
|
||||
ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
// The local side always loses a scalar clash — see ThreeWayMerge — so the discarded side is
|
||||
// fixed here rather than derived from the outcome.
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
nameof(HostGroupSecret.Label),
|
||||
MergeSide.Local,
|
||||
merge.Value,
|
||||
merge.Discarded,
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return new HostGroupMergeResult(new HostGroupSecret { Label = merge.Value }, conflicts);
|
||||
}
|
||||
}
|
||||
@@ -101,6 +101,32 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// </remarks>
|
||||
public Guid? CredentialId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The group this host is filed under, or null for none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The pointer lives on the host rather than a member list living on the group, and the reason is the
|
||||
/// merge: filing two different hosts into one group on two machines has to be two writes to two items.
|
||||
/// Held the other way round it would be two writes to one item, and with no set merge available the
|
||||
/// collision would resolve by one side winning and the other host quietly leaving the group.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Inside the payload, and it did not have to be.</b> <c>SyncPlaintextFields</c> has carried a
|
||||
/// <c>GroupId</c> since the contract was frozen and the server had a column for it. Nothing ever wrote
|
||||
/// one, the column is gone, and the server now refuses the field — because what it would hand over is a
|
||||
/// clustering of the estate, and the one plaintext concession the design allows itself is the relay
|
||||
/// address, which the relay genuinely cannot work without. This is not that. See ADR 0004.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The reference may dangle</b>, exactly as <see cref="SshKeyId"/> may: a group deleted on another
|
||||
/// machine leaves this pointing at nothing. That is handled where it is noticed — the host appears under
|
||||
/// the ungrouped heading — rather than prevented here, because preventing it would mean one group delete
|
||||
/// rewriting every host that named it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Guid? GroupId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this host may be dialled through the server relay.
|
||||
/// </summary>
|
||||
@@ -174,6 +200,12 @@ public sealed record HostSecret : IVaultSecret
|
||||
return false;
|
||||
}
|
||||
|
||||
if (GroupId == Guid.Empty)
|
||||
{
|
||||
reason = "A group reference cannot be an empty id; use no group instead.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -62,8 +62,11 @@ public static class HostSecretCodec
|
||||
/// <summary>The version that introduced <see cref="HostSecret.CredentialId"/>.</summary>
|
||||
public const int CredentialIdSchemaVersion = 3;
|
||||
|
||||
/// <summary>The version that introduced <see cref="HostSecret.GroupId"/>.</summary>
|
||||
public const int GroupIdSchemaVersion = 4;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
public const int CurrentSchemaVersion = CredentialIdSchemaVersion;
|
||||
public const int CurrentSchemaVersion = GroupIdSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -95,6 +98,7 @@ public static class HostSecretCodec
|
||||
RelayEnabled = host.RelayEnabled,
|
||||
SshKeyId = host.SshKeyId,
|
||||
CredentialId = host.CredentialId,
|
||||
GroupId = host.GroupId,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -120,18 +124,40 @@ public static class HostSecretCodec
|
||||
/// did not make every host in every vault look like a change to the sync engine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The two bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so this reads as
|
||||
/// a ladder rather than a maximum. If a future field is <em>not</em> exclusive with an older one, this
|
||||
/// becomes the maximum over the versions of the fields present, which is the same rule stated more
|
||||
/// generally.
|
||||
/// <b>A maximum, not a ladder, and the difference arrived with <see cref="HostSecret.GroupId"/>.</b> The
|
||||
/// two authentication bindings are mutually exclusive — see <see cref="HostSecret.CredentialId"/> — so
|
||||
/// while they were the only versioned fields, a <c>switch</c> that returned the first match was
|
||||
/// indistinguishable from the rule and read more clearly. A group is orthogonal to both: a host can name
|
||||
/// a credential <em>and</em> a group, and the ladder would have answered 3 for it, writing a version that
|
||||
/// cannot represent the group it just wrote. An older client would then decode that host as editable and
|
||||
/// drop the field on the next save.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written as a maximum over the fields actually present, which is the general form of the same rule and
|
||||
/// stays correct however the next field relates to these.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static int SchemaVersionFor(HostSecret host) => host switch
|
||||
private static int SchemaVersionFor(HostSecret host)
|
||||
{
|
||||
{ CredentialId: not null } => CredentialIdSchemaVersion,
|
||||
{ SshKeyId: not null } => SshKeyIdSchemaVersion,
|
||||
_ => BaseSchemaVersion,
|
||||
};
|
||||
var version = BaseSchemaVersion;
|
||||
|
||||
if (host.SshKeyId is not null)
|
||||
{
|
||||
version = Math.Max(version, SshKeyIdSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.CredentialId is not null)
|
||||
{
|
||||
version = Math.Max(version, CredentialIdSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.GroupId is not null)
|
||||
{
|
||||
version = Math.Max(version, GroupIdSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a decrypted payload.
|
||||
@@ -199,6 +225,7 @@ public static class HostSecretCodec
|
||||
RelayEnabled = parsed.RelayEnabled,
|
||||
SshKeyId = parsed.SshKeyId,
|
||||
CredentialId = parsed.CredentialId,
|
||||
GroupId = parsed.GroupId,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
@@ -254,6 +281,9 @@ internal sealed class HostPayloadDocument
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public Guid? CredentialId { get; set; }
|
||||
|
||||
/// <inheritdoc cref="SshKeyId" />
|
||||
public Guid? GroupId { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
|
||||
@@ -103,10 +103,35 @@ public static class HostSecretMerge
|
||||
remote.RelayEnabled,
|
||||
conflicts,
|
||||
static enabled => enabled ? "enabled" : "disabled"),
|
||||
};
|
||||
|
||||
// The id is shown in a clash rather than redacted. It is not a secret — it names a vault item,
|
||||
// it is not the key — and hiding it would leave the user unable to tell which of two keys the
|
||||
// merge dropped.
|
||||
return new HostMergeResult(
|
||||
WithReferences(merged, ancestor, local, remote, conflicts), conflicts);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges the three ids a host can point at: its key, its credential and its group.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Split out for length, and they do belong together: each is a reference to another vault item, each
|
||||
/// merges as a plain scalar, and each can end up dangling because the item it names may be deleted on
|
||||
/// another machine. None of that is the merge's problem — it is handled where the reference is used.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The ids are shown in a clash rather than redacted.</b> An id is not a secret — it names a vault
|
||||
/// item, it is not the key — and hiding it would leave the user unable to tell which of two keys the
|
||||
/// merge dropped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static HostSecret WithReferences(
|
||||
HostSecret merged,
|
||||
HostSecret ancestor,
|
||||
HostSecret local,
|
||||
HostSecret remote,
|
||||
List<HostFieldConflict> conflicts) =>
|
||||
merged with
|
||||
{
|
||||
SshKeyId = Field(
|
||||
nameof(HostSecret.SshKeyId),
|
||||
ancestor.SshKeyId,
|
||||
@@ -122,10 +147,15 @@ public static class HostSecretMerge
|
||||
remote.CredentialId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "no credential"),
|
||||
};
|
||||
|
||||
return new HostMergeResult(merged, conflicts);
|
||||
}
|
||||
GroupId = Field(
|
||||
nameof(HostSecret.GroupId),
|
||||
ancestor.GroupId,
|
||||
local.GroupId,
|
||||
remote.GroupId,
|
||||
conflicts,
|
||||
static id => id?.ToString() ?? "ungrouped"),
|
||||
};
|
||||
|
||||
private static string Text(
|
||||
string name,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged entry, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The entry to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record ConnectionLogMergeResult(
|
||||
ConnectionLogSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogMergeResult" />
|
||||
public sealed record ActivityLogMergeResult(
|
||||
ActivityLogSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a connection log entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This exists because the item-kind pipeline requires it, and it should never run.</b> A log entry is
|
||||
/// written once, at the moment a connection closes, and nothing updates one — so there is no second version
|
||||
/// for a first to diverge from. Reaching this code means two clients wrote different records under one
|
||||
/// entity id, and entity ids are v7 GUIDs minted independently on each machine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is still a real merge rather than a throw. The reconciler runs inside a sync pass, and an exception
|
||||
/// there would strand every item queued behind this one — for a situation that is a bug in some client and
|
||||
/// not an emergency. So the remote side wins, the difference is recorded like any other, and somebody reads
|
||||
/// a conflict notice about a log entry, which is the loudest signal this could reasonably give.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing is redacted. Every field is already an audit record of something that happened, and a notice that
|
||||
/// hid which of two records was dropped would defeat the point of noticing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ConnectionLogSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged entry.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static ConnectionLogMergeResult Merge(
|
||||
ConnectionLogSecret ancestor,
|
||||
ConnectionLogSecret local,
|
||||
ConnectionLogSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
// Whole-value, not field by field. The fields of one entry describe one event, and a merge that took
|
||||
// the host from one side and the duration from the other would invent a connection nobody made —
|
||||
// which is a worse outcome than losing the record this machine happened to hold.
|
||||
if (local == remote)
|
||||
{
|
||||
return new ConnectionLogMergeResult(remote, []);
|
||||
}
|
||||
|
||||
return new ConnectionLogMergeResult(
|
||||
remote,
|
||||
[
|
||||
new HostFieldConflict(
|
||||
"Entry",
|
||||
MergeSide.Local,
|
||||
remote.Label,
|
||||
local.Label,
|
||||
DiscardedWasRemoval: false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of an activity log entry.
|
||||
/// </summary>
|
||||
/// <inheritdoc cref="ConnectionLogSecretMerge" path="/remarks" />
|
||||
public static class ActivityLogSecretMerge
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionLogSecretMerge.Merge" />
|
||||
public static ActivityLogMergeResult Merge(
|
||||
ActivityLogSecret ancestor,
|
||||
ActivityLogSecret local,
|
||||
ActivityLogSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
if (local == remote)
|
||||
{
|
||||
return new ActivityLogMergeResult(remote, []);
|
||||
}
|
||||
|
||||
return new ActivityLogMergeResult(
|
||||
remote,
|
||||
[
|
||||
new HostFieldConflict(
|
||||
"Entry",
|
||||
MergeSide.Local,
|
||||
remote.Label,
|
||||
local.Label,
|
||||
DiscardedWasRemoval: false),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// An S3-compatible bucket and the credentials that reach it, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Named for the protocol rather than for Amazon, because everything here works the same against MinIO, R2,
|
||||
/// Backblaze or Ceph — and for those, <see cref="Endpoint"/> is an address on somebody's own network. The
|
||||
/// interface says S3, which is what people call the protocol; the type says what it is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="SecretAccessKey"/> is a password, and everything this codebase does about passwords applies
|
||||
/// to it.</b> It is inside the encrypted payload, it never appears in a log line — the activity log records
|
||||
/// the field's name and not its value — and the merge reports that it differed rather than what it was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record ObjectStoreSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the user calls this bucket. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>The bucket.</summary>
|
||||
public required string Bucket { get; init; }
|
||||
|
||||
/// <summary>The access key id.</summary>
|
||||
public required string AccessKeyId { get; init; }
|
||||
|
||||
/// <summary>The secret access key.</summary>
|
||||
public required string SecretAccessKey { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The region, or null to let the endpoint decide.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Required by AWS and ignored by several S3-compatible services, which is why it is nullable rather than
|
||||
/// defaulted to <c>us-east-1</c>. A default would be a guess presented as configuration, and the guess is
|
||||
/// wrong for exactly the self-hosted case this field exists to support.
|
||||
/// </remarks>
|
||||
public string? Region { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The service endpoint, or null for Amazon's own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null means AWS and the SDK resolves the host from <see cref="Region"/>. Anything else is a URL, and it
|
||||
/// is the field that makes this work against a MinIO in a cupboard.
|
||||
/// </remarks>
|
||||
public string? Endpoint { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether to address the bucket as a path rather than as a subdomain.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>https://endpoint/bucket/key</c> instead of <c>https://bucket.endpoint/key</c>. Off for AWS, on for
|
||||
/// nearly every self-hosted service — MinIO in its default configuration has no wildcard DNS, so
|
||||
/// virtual-host addressing simply does not resolve. It is a setting rather than a guess because getting
|
||||
/// it wrong produces a name-resolution failure that says nothing about buckets.
|
||||
/// </remarks>
|
||||
public bool UsePathStyle { get; init; }
|
||||
|
||||
/// <summary>Free-text notes.</summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// The endpoint is checked for being a well-formed absolute URL when it is set at all. A relative one, or
|
||||
/// a bare hostname, produces an SDK failure at the first request whose message names neither the field
|
||||
/// nor this bucket — and the person reading it has typically just typed the value.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A bucket needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Bucket))
|
||||
{
|
||||
reason = "A bucket needs the bucket it points at.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(AccessKeyId) || string.IsNullOrWhiteSpace(SecretAccessKey))
|
||||
{
|
||||
reason = "A bucket needs an access key id and a secret access key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Endpoint is not null)
|
||||
{
|
||||
if (!Uri.TryCreate(Endpoint, UriKind.Absolute, out var endpoint))
|
||||
{
|
||||
reason = "The endpoint has to be a full URL, like https://minio.internal:9000.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal)
|
||||
&& !string.Equals(endpoint.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal))
|
||||
{
|
||||
reason = "The endpoint has to be http or https.";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Region is null && Endpoint is null)
|
||||
{
|
||||
// With neither, the SDK has nothing to resolve a host from and fails at the first request with
|
||||
// a message about a missing region rather than about this bucket.
|
||||
reason = "A bucket needs a region, an endpoint, or both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded bucket payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Store">The bucket.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record ObjectStoreSecretDocument(ObjectStoreSecret Store, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > ObjectStoreSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a bucket item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="CredentialSecretCodec"/>, for the same reasons and with the same guarantees.
|
||||
/// </remarks>
|
||||
public static class ObjectStoreSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a bucket to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The bucket is not valid for storage.</exception>
|
||||
public static byte[] Encode(ObjectStoreSecret store)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
|
||||
if (!store.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(store));
|
||||
}
|
||||
|
||||
var document = new ObjectStorePayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = store.Label,
|
||||
Bucket = store.Bucket,
|
||||
AccessKeyId = store.AccessKeyId,
|
||||
SecretAccessKey = store.SecretAccessKey,
|
||||
Region = store.Region,
|
||||
Endpoint = store.Endpoint,
|
||||
UsePathStyle = store.UsePathStyle,
|
||||
Notes = store.Notes,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out ObjectStoreSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
ObjectStorePayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, ObjectStorePayloadJsonContext.Default.ObjectStorePayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new ObjectStoreSecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
Bucket = parsed.Bucket ?? string.Empty,
|
||||
AccessKeyId = parsed.AccessKeyId ?? string.Empty,
|
||||
SecretAccessKey = parsed.SecretAccessKey ?? string.Empty,
|
||||
Region = parsed.Region,
|
||||
Endpoint = parsed.Endpoint,
|
||||
UsePathStyle = parsed.UsePathStyle,
|
||||
Notes = parsed.Notes,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new ObjectStoreSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class ObjectStorePayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
public string? Bucket { get; set; }
|
||||
|
||||
public string? AccessKeyId { get; set; }
|
||||
|
||||
public string? SecretAccessKey { get; set; }
|
||||
|
||||
public string? Region { get; set; }
|
||||
|
||||
public string? Endpoint { get; set; }
|
||||
|
||||
public bool UsePathStyle { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(ObjectStorePayloadDocument))]
|
||||
internal sealed partial class ObjectStorePayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,107 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged bucket, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The bucket to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record ObjectStoreMergeResult(
|
||||
ObjectStoreSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a bucket against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every field is a scalar, so this is <see cref="CredentialSecretMerge"/>'s shape and it reuses
|
||||
/// <see cref="HostFieldConflict"/> for the same reason.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The secret access key never reaches the conflict log</b>, exactly as a password does not: a discarded
|
||||
/// one is very often still live on the service it belongs to. The access key <em>id</em> is shown, because it
|
||||
/// is an identifier rather than a secret and knowing which of two key pairs the merge dropped is the whole
|
||||
/// content of the notice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class ObjectStoreSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged bucket.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static ObjectStoreMergeResult Merge(
|
||||
ObjectStoreSecret ancestor,
|
||||
ObjectStoreSecret local,
|
||||
ObjectStoreSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merged = new ObjectStoreSecret
|
||||
{
|
||||
// Null-forgiving on the required fields, as the neighbouring merges do: the merge returns one of
|
||||
// its three inputs, and all three are non-null by construction.
|
||||
Label = Resolve(
|
||||
nameof(ObjectStoreSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
|
||||
Bucket = Resolve(
|
||||
nameof(ObjectStoreSecret.Bucket), ancestor.Bucket, local.Bucket, remote.Bucket, conflicts)!,
|
||||
AccessKeyId = Resolve(
|
||||
nameof(ObjectStoreSecret.AccessKeyId),
|
||||
ancestor.AccessKeyId,
|
||||
local.AccessKeyId,
|
||||
remote.AccessKeyId,
|
||||
conflicts)!,
|
||||
SecretAccessKey = Resolve(
|
||||
nameof(ObjectStoreSecret.SecretAccessKey),
|
||||
ancestor.SecretAccessKey,
|
||||
local.SecretAccessKey,
|
||||
remote.SecretAccessKey,
|
||||
conflicts,
|
||||
redact: true)!,
|
||||
Region = Resolve(
|
||||
nameof(ObjectStoreSecret.Region), ancestor.Region, local.Region, remote.Region, conflicts),
|
||||
Endpoint = Resolve(
|
||||
nameof(ObjectStoreSecret.Endpoint),
|
||||
ancestor.Endpoint,
|
||||
local.Endpoint,
|
||||
remote.Endpoint,
|
||||
conflicts),
|
||||
UsePathStyle = ThreeWayMerge
|
||||
.Scalar(ancestor.UsePathStyle, local.UsePathStyle, remote.UsePathStyle)
|
||||
.Value,
|
||||
Notes = Resolve(
|
||||
nameof(ObjectStoreSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
|
||||
};
|
||||
|
||||
return new ObjectStoreMergeResult(merged, conflicts);
|
||||
}
|
||||
|
||||
private static string? Resolve(
|
||||
string name,
|
||||
string? ancestor,
|
||||
string? local,
|
||||
string? remote,
|
||||
List<HostFieldConflict> conflicts,
|
||||
bool redact = false)
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
name,
|
||||
MergeSide.Local,
|
||||
redact ? "(kept the server's value)" : merge.Value ?? "(none)",
|
||||
redact ? "(a different value was discarded)" : merge.Discarded ?? "(none)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return merge.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A saved command, decrypted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b><see cref="RunsOnInsert"/> is the field this type exists to get right.</b> A terminal is one input
|
||||
/// stream with no notion of "at a prompt": the remote may be inside <c>vi</c>, or at a <c>sudo</c> password
|
||||
/// prompt with echo off, and without shell integration the client cannot tell. So inserting a snippet is
|
||||
/// always "type this into whatever is there", never "run this command" — and whether a newline follows the
|
||||
/// text is the difference between the user reading what appeared and deciding, and something happening.
|
||||
/// It defaults to <see langword="false"/>, which makes that decision the user's Enter key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Command"/> is stored verbatim.</b> No trimming, no newline normalisation — the same rule
|
||||
/// <see cref="SshKeySecret.PrivateKeyPem"/> follows, for a related reason: a heredoc's trailing newline is
|
||||
/// load-bearing, and a shell that receives a here-document terminator with the whitespace tidied off it hangs
|
||||
/// waiting for one that never comes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Deliberately not in this version, each with a reason rather than an omission: <b>host scoping</b>, which
|
||||
/// needs a set merge that <see cref="ThreeWayMerge"/> does not have; <b>tags</b>, which are their own reserved
|
||||
/// item kind; and <b>parameter substitution</b>, which would make this a template language expanding into a
|
||||
/// root shell — a second security surface for a feature whose first one is already the hard part.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SnippetSecret : IVaultSecret
|
||||
{
|
||||
/// <summary>What the snippet is called. The only name it has anywhere.</summary>
|
||||
public required string Label { get; init; }
|
||||
|
||||
/// <summary>The text to insert. May be several lines.</summary>
|
||||
public required string Command { get; init; }
|
||||
|
||||
/// <summary>Free-text notes.</summary>
|
||||
public string? Notes { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether inserting this also presses Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Off unless the user turns it on, per snippet. A vault-wide preference was the alternative and it is
|
||||
/// worse: the setting belongs to the command, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c>
|
||||
/// do not want the same answer, and a single switch would eventually be left on by whoever needed it for
|
||||
/// the first of those.
|
||||
/// </remarks>
|
||||
public bool RunsOnInsert { get; init; }
|
||||
|
||||
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Command"/> is checked for being blank but for nothing else. What makes a valid command is
|
||||
/// the remote shell's business, this client does not know which shell that is, and a validator guessing
|
||||
/// at it would refuse the legitimate cases — a bare <c>\x03</c>, a partial line meant to be completed by
|
||||
/// hand — while catching nothing that matters.
|
||||
/// </remarks>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Label))
|
||||
{
|
||||
reason = "A snippet needs a name.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(Command))
|
||||
{
|
||||
reason = "A snippet needs something to insert.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>A decoded snippet payload, together with the schema version it was written at.</summary>
|
||||
/// <param name="Snippet">The snippet.</param>
|
||||
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||
public sealed record SnippetSecretDocument(SnippetSecret Snippet, int SchemaVersion)
|
||||
{
|
||||
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||
public bool IsReadOnly => SchemaVersion > SnippetSecretCodec.CurrentSchemaVersion;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Encodes and decodes the plaintext inside a snippet item's encrypted payload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Mirrors <see cref="CredentialSecretCodec"/>. The one thing to be careful about here is
|
||||
/// <see cref="SnippetSecret.RunsOnInsert"/>: it is a <see cref="bool"/>, so a payload that omits it decodes
|
||||
/// as <see langword="false"/> — which is the safe direction, and deliberately the one a malformed or
|
||||
/// truncated write falls in.
|
||||
/// </remarks>
|
||||
public static class SnippetSecretCodec
|
||||
{
|
||||
/// <summary>The schema version this build writes.</summary>
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
/// <summary>Serialises a snippet to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The snippet is not valid for storage.</exception>
|
||||
public static byte[] Encode(SnippetSecret snippet)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snippet);
|
||||
|
||||
if (!snippet.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(snippet));
|
||||
}
|
||||
|
||||
var document = new SnippetPayloadDocument
|
||||
{
|
||||
SchemaVersion = CurrentSchemaVersion,
|
||||
Label = snippet.Label,
|
||||
Command = snippet.Command,
|
||||
Notes = snippet.Notes,
|
||||
RunsOnInsert = snippet.RunsOnInsert,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
document, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
|
||||
}
|
||||
|
||||
/// <summary>Parses a decrypted payload.</summary>
|
||||
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||
public static bool TryDecode(
|
||||
ReadOnlySpan<byte> payload,
|
||||
[NotNullWhen(true)] out SnippetSecretDocument? document)
|
||||
{
|
||||
document = null;
|
||||
|
||||
SnippetPayloadDocument? parsed;
|
||||
try
|
||||
{
|
||||
parsed = JsonSerializer.Deserialize(
|
||||
payload, SnippetPayloadJsonContext.Default.SnippetPayloadDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parsed is null || parsed.SchemaVersion < 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var candidate = new SnippetSecret
|
||||
{
|
||||
Label = parsed.Label ?? string.Empty,
|
||||
Command = parsed.Command ?? string.Empty,
|
||||
Notes = parsed.Notes,
|
||||
RunsOnInsert = parsed.RunsOnInsert,
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
document = new SnippetSecretDocument(candidate, parsed.SchemaVersion);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape. Mutable and nullable because it models untrusted input.</summary>
|
||||
/// <inheritdoc cref="HostPayloadDocument" path="/remarks" />
|
||||
internal sealed class SnippetPayloadDocument
|
||||
{
|
||||
public int SchemaVersion { get; set; }
|
||||
|
||||
public string? Label { get; set; }
|
||||
|
||||
public string? Command { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Not nullable, so its absence is <see langword="false"/> rather than a third state. The field decides
|
||||
/// whether inserting a snippet also presses Enter, and "we could not tell" has to resolve to the answer
|
||||
/// that does nothing.
|
||||
/// </remarks>
|
||||
public bool RunsOnInsert { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SnippetPayloadDocument))]
|
||||
internal sealed partial class SnippetPayloadJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>The merged snippet, and everything that had to be overridden to produce it.</summary>
|
||||
/// <param name="Merged">The snippet to store and push.</param>
|
||||
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||
public sealed record SnippetMergeResult(
|
||||
SnippetSecret Merged,
|
||||
IReadOnlyList<HostFieldConflict> Conflicts)
|
||||
{
|
||||
/// <summary>Whether anything had to be overridden.</summary>
|
||||
public bool HasConflicts => Conflicts.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges two divergent versions of a snippet against the version they both started from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Three strings and a flag, so the shape is <see cref="CredentialSecretMerge"/>'s and it reuses
|
||||
/// <see cref="HostFieldConflict"/> for the same reason. Nothing is redacted: a snippet is a command somebody
|
||||
/// wrote down on purpose, and a notice that hid the discarded version would leave the user unable to tell
|
||||
/// whether the one that survived is the one they wanted to keep.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="SnippetSecret.RunsOnInsert"/> cannot conflict, and it is worth knowing why rather than
|
||||
/// assuming it.</b> A three-way clash needs local and remote each to differ from the ancestor <em>and</em>
|
||||
/// from one another; with only two possible values, the first two conditions force the third to fail. So this
|
||||
/// field always resolves to whichever side actually changed it, and a merge can never turn a snippet into one
|
||||
/// that runs on its own — the outcome the ordinary rule would have made possible if the field had a third
|
||||
/// state. An earlier draft special-cased it to resolve to <see langword="false"/> on a clash; the branch was
|
||||
/// unreachable, and unreachable safety code is worse than none, because it reads as protection.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SnippetSecretMerge
|
||||
{
|
||||
/// <summary>Produces the merged snippet.</summary>
|
||||
/// <param name="ancestor">The version both sides branched from.</param>
|
||||
/// <param name="local">The pending local version.</param>
|
||||
/// <param name="remote">The server's current version.</param>
|
||||
public static SnippetMergeResult Merge(
|
||||
SnippetSecret ancestor,
|
||||
SnippetSecret local,
|
||||
SnippetSecret remote)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ancestor);
|
||||
ArgumentNullException.ThrowIfNull(local);
|
||||
ArgumentNullException.ThrowIfNull(remote);
|
||||
|
||||
var conflicts = new List<HostFieldConflict>();
|
||||
|
||||
var merged = new SnippetSecret
|
||||
{
|
||||
// Null-forgiving on the two required fields, as the neighbouring merges do for the same reason:
|
||||
// the merge returns one of its three inputs, and all three are non-null by construction.
|
||||
Label = Text(
|
||||
nameof(SnippetSecret.Label), ancestor.Label, local.Label, remote.Label, conflicts)!,
|
||||
Command = Text(
|
||||
nameof(SnippetSecret.Command),
|
||||
ancestor.Command,
|
||||
local.Command,
|
||||
remote.Command,
|
||||
conflicts)!,
|
||||
Notes = Text(
|
||||
nameof(SnippetSecret.Notes), ancestor.Notes, local.Notes, remote.Notes, conflicts),
|
||||
RunsOnInsert = ThreeWayMerge
|
||||
.Scalar(ancestor.RunsOnInsert, local.RunsOnInsert, remote.RunsOnInsert)
|
||||
.Value,
|
||||
};
|
||||
|
||||
return new SnippetMergeResult(merged, conflicts);
|
||||
}
|
||||
|
||||
private static string? Text(
|
||||
string name,
|
||||
string? ancestor,
|
||||
string? local,
|
||||
string? remote,
|
||||
List<HostFieldConflict> conflicts)
|
||||
{
|
||||
var merge = ThreeWayMerge.Scalar(ancestor, local, remote, StringComparer.Ordinal);
|
||||
|
||||
if (merge.IsConflicted)
|
||||
{
|
||||
conflicts.Add(new HostFieldConflict(
|
||||
name,
|
||||
MergeSide.Local,
|
||||
merge.Value ?? "(none)",
|
||||
merge.Discarded ?? "(none)",
|
||||
DiscardedWasRemoval: false));
|
||||
}
|
||||
|
||||
return merge.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Reads the moment a version 7 identifier was created back out of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this exists.</b> No vault item carries a timestamp. <c>VaultItem</c> is an id, a secret, a version
|
||||
/// and three sync flags, and the server's <c>created_at</c> is deliberately not handed back — so a screen
|
||||
/// that wants to say when something was added has nothing to read. Every id this client mints goes through
|
||||
/// <see cref="Guid.CreateVersion7()"/>, which is banned-symbol policy rather than preference (see
|
||||
/// <c>BannedSymbols.txt</c>), and RFC 9562 puts 48 bits of Unix milliseconds in the first six bytes of one.
|
||||
/// That is a real creation time, already stored, costing nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What it is not.</b> It is when the item was <em>created</em>, never when it was last changed — an
|
||||
/// update keeps the id. A screen showing this has to say so, or it is quietly presenting a creation date as
|
||||
/// a modification date. And an id minted anywhere else, by an older client or another implementation, is not
|
||||
/// a v7 at all; that case answers null rather than a number derived from bytes that mean something else.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class Uuid7Timestamp
|
||||
{
|
||||
/// <summary>Where the version nibble lives in the RFC byte order.</summary>
|
||||
private const int VersionByte = 6;
|
||||
|
||||
/// <summary>
|
||||
/// The creation time recorded in a version 7 identifier, or null if it is not one.
|
||||
/// </summary>
|
||||
public static DateTimeOffset? Of(Guid id)
|
||||
{
|
||||
Span<byte> bytes = stackalloc byte[16];
|
||||
|
||||
// Big-endian, which is the whole reason this is not two lines of shifting. Guid's own layout stores
|
||||
// its first three fields in the host's byte order, so the little-endian overload scrambles exactly
|
||||
// the six bytes being read here — and does it silently, producing dates in the year 30000 rather
|
||||
// than an error.
|
||||
if (!id.TryWriteBytes(bytes, bigEndian: true, out _))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if ((bytes[VersionByte] & 0xF0) != 0x70)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
long milliseconds = 0;
|
||||
|
||||
for (var i = 0; i < 6; i++)
|
||||
{
|
||||
milliseconds = (milliseconds << 8) | bytes[i];
|
||||
}
|
||||
|
||||
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
Reading an OpenSSH client configuration and turning it into hosts this application can store.
|
||||
|
||||
Its own project rather than a folder in DodoSSH.Client.Domain, which holds decrypted item shapes and
|
||||
their codecs and has no package references at all. A parser, a resolver and a file-system walk are a
|
||||
different concern with different dependencies, and keeping them apart is what lets the whole of the
|
||||
parsing be tested with no Avalonia, no SQLite and no disk.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DodoSSH.Client.Domain\DodoSSH.Client.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Import.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,102 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Import;
|
||||
|
||||
/// <summary>
|
||||
/// One host an <c>ssh_config</c> describes, resolved and ready to be looked at.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not a <see cref="HostSecret"/>. This is a candidate somebody has not agreed to import yet,
|
||||
/// and it carries things a stored host has no field for — the identity file's path, the jump alias by name,
|
||||
/// and the warnings that go beside a row in the preview.
|
||||
/// </remarks>
|
||||
/// <param name="Alias">
|
||||
/// The name from the <c>Host</c> line, which is what the user types after <c>ssh</c> and so the name they
|
||||
/// will recognise.
|
||||
/// </param>
|
||||
/// <param name="Hostname">
|
||||
/// What <c>HostName</c> said, or the alias when it said nothing — which is OpenSSH's own default and the
|
||||
/// reason <c>Host db.internal</c> with no other directive works.
|
||||
/// </param>
|
||||
/// <param name="Username">What <c>User</c> said, if anything.</param>
|
||||
/// <param name="Port">What <c>Port</c> said, defaulting to 22.</param>
|
||||
/// <param name="IdentityFiles">Every <c>IdentityFile</c> path, in the order they were given.</param>
|
||||
/// <param name="ProxyJump">The <c>ProxyJump</c> value verbatim, if any.</param>
|
||||
/// <param name="Options">Everything else, as SSH directives.</param>
|
||||
/// <param name="Warnings">What could not be represented, per host.</param>
|
||||
public sealed record ImportedHost(
|
||||
string Alias,
|
||||
string Hostname,
|
||||
string? Username,
|
||||
int Port,
|
||||
IReadOnlyList<string> IdentityFiles,
|
||||
string? ProxyJump,
|
||||
HostOptions Options,
|
||||
IReadOnlyList<string> Warnings)
|
||||
{
|
||||
/// <summary>The address this would dial, for a preview row.</summary>
|
||||
public string Address => Username is { Length: > 0 } user
|
||||
? $"{user}@{Hostname}:{Port}"
|
||||
: $"{Hostname}:{Port}";
|
||||
|
||||
/// <summary>Turns this into the host that would be stored.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The identity file becomes a note and a directive, not a key.</b> Reading somebody's
|
||||
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate,
|
||||
/// and doing it as a side effect of "import my config" is the wrong default. The path is recorded so it
|
||||
/// is not lost; importing the material is a separate, per-row choice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>ProxyJump records intent and changes nothing about connecting.</b> The SSH layer has no jump
|
||||
/// hosts — <c>ISshConnection</c> offers <c>OpenShellAsync</c> and nothing else, and
|
||||
/// <c>SshConnectionRequest</c> has no route field. So it is kept as a directive and a note, and the
|
||||
/// preview says so; a bastion topology that imported and quietly did not route would be worse than one
|
||||
/// that was not imported.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public HostSecret ToSecret()
|
||||
{
|
||||
var options = new List<HostOption>(Options);
|
||||
var notes = new List<string>();
|
||||
|
||||
if (IdentityFiles.Count > 0)
|
||||
{
|
||||
options.Add(new HostOption("IdentityFile", IdentityFiles[0]));
|
||||
|
||||
notes.Add(IdentityFiles.Count == 1
|
||||
? $"ssh_config used the key at {IdentityFiles[0]}."
|
||||
: $"ssh_config listed {IdentityFiles.Count} keys, the first being {IdentityFiles[0]}.");
|
||||
}
|
||||
|
||||
if (ProxyJump is { Length: > 0 } jump)
|
||||
{
|
||||
options.Add(new HostOption("ProxyJump", jump));
|
||||
notes.Add($"ssh_config reached this through {jump}. DodoSSH does not route through a jump host yet.");
|
||||
}
|
||||
|
||||
return new HostSecret
|
||||
{
|
||||
Label = Alias,
|
||||
Hostname = Hostname,
|
||||
Port = Port,
|
||||
Username = Username,
|
||||
Notes = notes.Count == 0 ? null : string.Join(" ", notes),
|
||||
Options = HostOptions.Create(options),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything an <c>ssh_config</c> yielded: the hosts it can offer, and what it could not.
|
||||
/// </summary>
|
||||
/// <param name="Hosts">The importable candidates, in file order.</param>
|
||||
/// <param name="SkippedPatterns">
|
||||
/// <c>Host</c> patterns that are patterns rather than names. They contribute defaults and are not
|
||||
/// importable: a bookmark called <c>*.internal</c> is one nothing can dial.
|
||||
/// </param>
|
||||
/// <param name="Warnings">Document-level notes, including the parser's own.</param>
|
||||
public sealed record SshConfigImport(
|
||||
IReadOnlyList<ImportedHost> Hosts,
|
||||
IReadOnlyList<string> SkippedPatterns,
|
||||
IReadOnlyList<string> Warnings);
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace DodoSSH.Client.Import;
|
||||
|
||||
/// <summary>One <c>Keyword Value</c> line, with the keyword as written.</summary>
|
||||
/// <param name="Keyword">The directive name. SSH keywords are case-insensitive; the case here is the file's.</param>
|
||||
/// <param name="Value">Everything after the keyword, unquoted but otherwise verbatim.</param>
|
||||
public sealed record SshConfigDirective(string Keyword, string Value);
|
||||
|
||||
/// <summary>
|
||||
/// One <c>Host</c> block: the patterns it applies to and the directives under it.
|
||||
/// </summary>
|
||||
/// <param name="Patterns">
|
||||
/// Every token on the <c>Host</c> line. One line can name several — <c>Host web1 web2 web3</c> — and any of
|
||||
/// them may be a pattern rather than a name.
|
||||
/// </param>
|
||||
/// <param name="Directives">The directives under it, in file order.</param>
|
||||
public sealed record SshConfigBlock(
|
||||
IReadOnlyList<string> Patterns,
|
||||
IReadOnlyList<SshConfigDirective> Directives);
|
||||
|
||||
/// <summary>
|
||||
/// A parsed <c>ssh_config</c>, plus what could not be honoured.
|
||||
/// </summary>
|
||||
/// <param name="Blocks">Every <c>Host</c> block, in the order OpenSSH would read them.</param>
|
||||
/// <param name="Warnings">
|
||||
/// What was skipped or flattened, in the words the preview will show. Everything this parser cannot
|
||||
/// represent ends up here rather than being dropped quietly — a config that half-imported without saying so
|
||||
/// is worse than one that refused.
|
||||
/// </param>
|
||||
public sealed record SshConfigDocument(
|
||||
IReadOnlyList<SshConfigBlock> Blocks,
|
||||
IReadOnlyList<string> Warnings);
|
||||
@@ -0,0 +1,80 @@
|
||||
namespace DodoSSH.Client.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Finds and reads the user's OpenSSH client configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The only type here that touches a disk, which is what keeps <see cref="SshConfigParser"/> and
|
||||
/// <see cref="SshConfigResolver"/> testable against strings.
|
||||
/// </remarks>
|
||||
public sealed class SshConfigLocator
|
||||
{
|
||||
private readonly string sshDirectory;
|
||||
|
||||
/// <param name="sshDirectory">
|
||||
/// Where to look. Defaults to <c>~/.ssh</c>, which is the location on Windows as well as everywhere
|
||||
/// else — OpenSSH on Windows uses the profile directory, not <c>%APPDATA%</c>.
|
||||
/// </param>
|
||||
public SshConfigLocator(string? sshDirectory = null) =>
|
||||
this.sshDirectory = sshDirectory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".ssh");
|
||||
|
||||
/// <summary>The file this would read.</summary>
|
||||
public string ConfigPath => Path.Combine(sshDirectory, "config");
|
||||
|
||||
/// <summary>Whether there is anything to read.</summary>
|
||||
public bool Exists => File.Exists(ConfigPath);
|
||||
|
||||
/// <summary>Reads and resolves the configuration.</summary>
|
||||
/// <exception cref="FileNotFoundException">There is no configuration file.</exception>
|
||||
public async Task<SshConfigImport> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var text = await File.ReadAllTextAsync(ConfigPath, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return SshConfigResolver.Resolve(SshConfigParser.Parse(text, ReadIncluded));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads every file an <c>Include</c> pattern names.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relative pattern resolves against <c>~/.ssh</c>, which is OpenSSH's rule for the user file. Glob
|
||||
/// characters are handled by enumerating the directory rather than by matching by hand — a pattern like
|
||||
/// <c>conf.d/*.conf</c> is the common shape and is what the enumeration overload is for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything here swallows its own failures and returns nothing. An <c>Include</c> naming a file that
|
||||
/// does not exist is not an error to OpenSSH, and an unreadable one is a reason to import less rather
|
||||
/// than a reason to import nothing — the parser records the shortfall in its warnings either way.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private IReadOnlyList<string> ReadIncluded(string pattern)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rooted = Path.IsPathRooted(pattern) ? pattern : Path.Combine(sshDirectory, pattern);
|
||||
var directory = Path.GetDirectoryName(rooted);
|
||||
var mask = Path.GetFileName(rooted);
|
||||
|
||||
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(mask) || !Directory.Exists(directory))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return [.. Directory
|
||||
.EnumerateFiles(directory, mask, SearchOption.TopDirectoryOnly)
|
||||
.Order(StringComparer.Ordinal)
|
||||
.Select(File.ReadAllText)];
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Reads an OpenSSH client configuration into blocks and directives.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Pure, and takes its include reader as a parameter.</b> That is what makes <c>Include</c> — the one
|
||||
/// directive whose behaviour depends on the file system — testable without a file system, and it keeps the
|
||||
/// recursion depth cap and the cycle detection here, next to the recursion, rather than in whatever happens
|
||||
/// to be doing the reading.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Deliberately not a complete implementation of ssh_config, and the gaps are reported rather than
|
||||
/// hidden.</b> <c>Match</c> blocks are not evaluated: <c>Match exec</c> runs a command, <c>Match host</c>
|
||||
/// depends on what is being connected to, and <c>Match final</c> depends on the result of everything else —
|
||||
/// none of which is knowable while looking at a file. Token expansion beyond <c>~</c>,
|
||||
/// <c>CanonicalizeHostname</c> and negated patterns are all out of scope for the same reason: this is an
|
||||
/// importer producing bookmarks somebody will check, not a second SSH client.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SshConfigParser
|
||||
{
|
||||
/// <summary>How deep <c>Include</c> may nest before this gives up.</summary>
|
||||
/// <remarks>
|
||||
/// OpenSSH's own limit is 16. Matching it means a config this refuses is one <c>ssh</c> refuses too,
|
||||
/// which is a better answer than a different arbitrary number.
|
||||
/// </remarks>
|
||||
private const int MaximumIncludeDepth = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Parses configuration text.
|
||||
/// </summary>
|
||||
/// <param name="text">The file's contents.</param>
|
||||
/// <param name="includeReader">
|
||||
/// Resolves an <c>Include</c> pattern to the contents of every file it names, in order. Return an empty
|
||||
/// sequence for a pattern that matches nothing, which is what OpenSSH does — an <c>Include</c> naming no
|
||||
/// file is not an error.
|
||||
/// </param>
|
||||
public static SshConfigDocument Parse(string text, Func<string, IReadOnlyList<string>>? includeReader = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
|
||||
var blocks = new List<SshConfigBlock>();
|
||||
var warnings = new List<string>();
|
||||
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
ParseInto(text, includeReader, blocks, warnings, visited, depth: 0);
|
||||
|
||||
return new SshConfigDocument(blocks, warnings);
|
||||
}
|
||||
|
||||
private static void ParseInto(
|
||||
string text,
|
||||
Func<string, IReadOnlyList<string>>? includeReader,
|
||||
List<SshConfigBlock> blocks,
|
||||
List<string> warnings,
|
||||
HashSet<string> visited,
|
||||
int depth)
|
||||
{
|
||||
List<string>? patterns = null;
|
||||
var directives = new List<SshConfigDirective>();
|
||||
|
||||
// A Match block is "everything until the next Host or Match", and while one is open its directives
|
||||
// are dropped rather than attributed to whatever block came before — which is what a naive parser
|
||||
// does, and it silently gives one host another host's settings.
|
||||
var insideMatch = false;
|
||||
var matchBlocks = 0;
|
||||
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var (keyword, value) = Tokenise(raw);
|
||||
|
||||
if (keyword is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Is(keyword, "Host"))
|
||||
{
|
||||
Flush(blocks, patterns, directives);
|
||||
|
||||
patterns = SplitPatterns(value);
|
||||
directives = [];
|
||||
insideMatch = false;
|
||||
}
|
||||
else if (Is(keyword, "Match"))
|
||||
{
|
||||
Flush(blocks, patterns, directives);
|
||||
|
||||
patterns = null;
|
||||
directives = [];
|
||||
insideMatch = true;
|
||||
matchBlocks++;
|
||||
}
|
||||
else if (insideMatch)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
else if (Is(keyword, "Include"))
|
||||
{
|
||||
// Flushed first, so the included file's blocks land between this block and the next — which
|
||||
// is where OpenSSH puts them, and it matters because the first value seen for a keyword is
|
||||
// the one that wins.
|
||||
Flush(blocks, patterns, directives);
|
||||
patterns = null;
|
||||
directives = [];
|
||||
|
||||
Include(value, includeReader, blocks, warnings, visited, depth);
|
||||
}
|
||||
else
|
||||
{
|
||||
directives.Add(new SshConfigDirective(keyword, value));
|
||||
}
|
||||
}
|
||||
|
||||
Flush(blocks, patterns, directives);
|
||||
|
||||
WarnAboutMatchBlocks(matchBlocks, warnings);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Counted rather than listed. What a reader needs is that some of their file was not honoured and why;
|
||||
/// naming each <c>Match</c> condition would be repeating the file back at them.
|
||||
/// </remarks>
|
||||
private static void WarnAboutMatchBlocks(int matchBlocks, List<string> warnings)
|
||||
{
|
||||
if (matchBlocks == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
warnings.Add(string.Create(
|
||||
CultureInfo.CurrentCulture,
|
||||
$"{matchBlocks} Match block(s) were ignored. Whether one applies depends on what is being connected to, or on a command's output, so it cannot be decided from the file alone."));
|
||||
}
|
||||
|
||||
private static bool Is(string keyword, string name) =>
|
||||
string.Equals(keyword, name, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static void Include(
|
||||
string pattern,
|
||||
Func<string, IReadOnlyList<string>>? includeReader,
|
||||
List<SshConfigBlock> blocks,
|
||||
List<string> warnings,
|
||||
HashSet<string> visited,
|
||||
int depth)
|
||||
{
|
||||
if (includeReader is null)
|
||||
{
|
||||
warnings.Add($"Include {pattern} was skipped: nothing was supplied to read included files.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (depth >= MaximumIncludeDepth)
|
||||
{
|
||||
warnings.Add($"Include {pattern} was skipped: includes are nested more than {MaximumIncludeDepth} deep.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Cycles are the reason this is a set rather than a counter. A file that includes itself — directly
|
||||
// or through a chain — would otherwise recurse until the depth cap, importing the same hosts sixteen
|
||||
// times before stopping, which reads as a bug in the importer rather than in the config.
|
||||
if (!visited.Add(pattern))
|
||||
{
|
||||
warnings.Add($"Include {pattern} was skipped: it is already being read further up.");
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var included in includeReader(pattern))
|
||||
{
|
||||
ParseInto(included, includeReader, blocks, warnings, visited, depth + 1);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
visited.Remove(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Flush(
|
||||
List<SshConfigBlock> blocks,
|
||||
List<string>? patterns,
|
||||
List<SshConfigDirective> directives)
|
||||
{
|
||||
if (patterns is { Count: > 0 })
|
||||
{
|
||||
blocks.Add(new SshConfigBlock(patterns, directives));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits one line into a keyword and a value, or nothing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OpenSSH accepts <c>Keyword Value</c>, <c>Keyword=Value</c> and <c>Keyword = Value</c>, allows leading
|
||||
/// whitespace, treats <c>#</c> as a comment, and lets a value be double-quoted. The quoting is what this
|
||||
/// has to get right rather than approximately right: <c>IdentityFile "~/my keys/id_ed25519"</c> is one
|
||||
/// path, and splitting it on whitespace produces two that do not exist.
|
||||
/// </remarks>
|
||||
private static (string? Keyword, string Value) Tokenise(string line)
|
||||
{
|
||||
// A BOM on the first line, and CR on every line of a CRLF file. Both are invisible and both would
|
||||
// otherwise end up inside the first keyword, where nothing matches them.
|
||||
var trimmed = line.Trim('', '\r').Trim();
|
||||
|
||||
if (trimmed.Length == 0 || trimmed[0] == '#')
|
||||
{
|
||||
return (null, string.Empty);
|
||||
}
|
||||
|
||||
var separator = trimmed.AsSpan().IndexOfAny(" \t=");
|
||||
|
||||
if (separator < 0)
|
||||
{
|
||||
return (trimmed, string.Empty);
|
||||
}
|
||||
|
||||
var keyword = trimmed[..separator];
|
||||
var rest = trimmed[separator..].TrimStart(' ', '\t');
|
||||
|
||||
if (rest.StartsWith('='))
|
||||
{
|
||||
rest = rest[1..].TrimStart(' ', '\t');
|
||||
}
|
||||
|
||||
return (keyword, Unquote(rest));
|
||||
}
|
||||
|
||||
private static string Unquote(string value)
|
||||
{
|
||||
var trimmed = value.Trim();
|
||||
|
||||
return trimmed.Length >= 2 && trimmed[0] == '"' && trimmed[^1] == '"'
|
||||
? trimmed[1..^1]
|
||||
: trimmed;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Each token unquoted separately, because <c>Host "my server" other</c> is two patterns and one of them
|
||||
/// contains a space.
|
||||
/// </remarks>
|
||||
private static List<string> SplitPatterns(string value)
|
||||
{
|
||||
var patterns = new List<string>();
|
||||
var span = value.AsSpan();
|
||||
var index = 0;
|
||||
|
||||
while (index < span.Length)
|
||||
{
|
||||
while (index < span.Length && char.IsWhiteSpace(span[index]))
|
||||
{
|
||||
index++;
|
||||
}
|
||||
|
||||
if (index >= span.Length)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
int end;
|
||||
|
||||
if (span[index] == '"')
|
||||
{
|
||||
index++;
|
||||
end = index;
|
||||
|
||||
while (end < span.Length && span[end] != '"')
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
patterns.Add(span[index..end].ToString());
|
||||
index = end + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
end = index;
|
||||
|
||||
while (end < span.Length && !char.IsWhiteSpace(span[end]))
|
||||
{
|
||||
end++;
|
||||
}
|
||||
|
||||
patterns.Add(span[index..end].ToString());
|
||||
index = end;
|
||||
}
|
||||
|
||||
return patterns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
using System.Buffers;
|
||||
using System.Globalization;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Import;
|
||||
|
||||
/// <summary>
|
||||
/// Turns parsed blocks into the hosts an import can offer.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>First value wins.</b> That is the actual OpenSSH rule and it is not the intuitive one — a later
|
||||
/// <c>Host *</c> block supplies defaults for keywords nothing earlier set, and cannot override a keyword an
|
||||
/// earlier block already set. Getting it backwards produces an import where every host has the wildcard
|
||||
/// block's username.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A block whose patterns are all wildcards contributes defaults and is not itself importable.</b>
|
||||
/// <c>Host *.internal</c> is a rule about names, not a machine — a bookmark by that name could not be
|
||||
/// dialled. Those are reported so the preview can say what was used and not imported, rather than leaving
|
||||
/// somebody to wonder why six blocks produced four hosts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SshConfigResolver
|
||||
{
|
||||
private static readonly SearchValues<char> PatternCharacters = SearchValues.Create("*?!");
|
||||
|
||||
/// <summary>Resolves every importable host in a parsed configuration.</summary>
|
||||
public static SshConfigImport Resolve(SshConfigDocument document)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(document);
|
||||
|
||||
var hosts = new List<ImportedHost>();
|
||||
var skipped = new List<string>();
|
||||
var warnings = new List<string>(document.Warnings);
|
||||
|
||||
foreach (var pattern in document.Blocks.SelectMany(block => block.Patterns).Where(IsPattern))
|
||||
{
|
||||
if (!skipped.Contains(pattern, StringComparer.Ordinal))
|
||||
{
|
||||
skipped.Add(pattern);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var alias in document.Blocks.SelectMany(block => block.Patterns).Where(name => !IsPattern(name)))
|
||||
{
|
||||
if (hosts.Any(host => string.Equals(host.Alias, alias, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
hosts.Add(Resolve(alias, document));
|
||||
}
|
||||
|
||||
if (skipped.Count > 0)
|
||||
{
|
||||
var named = string.Join(", ", skipped);
|
||||
|
||||
warnings.Add(string.Create(
|
||||
CultureInfo.CurrentCulture,
|
||||
$"{skipped.Count} pattern block(s) — {named} — supplied defaults but were not imported as hosts. A pattern names a rule, not a machine."));
|
||||
}
|
||||
|
||||
return new SshConfigImport(hosts, skipped, warnings);
|
||||
}
|
||||
|
||||
private static ImportedHost Resolve(string alias, SshConfigDocument document)
|
||||
{
|
||||
// Case-insensitive, because SSH keywords are and HostOption.NameComparer already says so. Two
|
||||
// spellings of ServerAliveInterval reaching HostOptions.Create would be a duplicate-name throw.
|
||||
var settled = new Dictionary<string, string>(HostOption.NameComparer);
|
||||
var identityFiles = new List<string>();
|
||||
var warnings = new List<string>();
|
||||
|
||||
Settle(alias, document, settled, identityFiles, warnings);
|
||||
|
||||
var port = ResolvePort(settled, warnings);
|
||||
var hostname = Take(settled, "HostName") ?? alias;
|
||||
var username = Take(settled, "User");
|
||||
var proxyJump = Take(settled, "ProxyJump");
|
||||
|
||||
if (Take(settled, "ProxyCommand") is { } proxyCommand)
|
||||
{
|
||||
// Not put into Options: it would look like a setting that does something. Nothing in this
|
||||
// application runs a ProxyCommand, and a directive sitting in a host's editor implying otherwise
|
||||
// is worse than a sentence saying it was dropped.
|
||||
warnings.Add($"ProxyCommand was dropped: nothing here runs one. It was '{proxyCommand}'.");
|
||||
}
|
||||
|
||||
return new ImportedHost(
|
||||
alias,
|
||||
hostname,
|
||||
username,
|
||||
port,
|
||||
identityFiles,
|
||||
proxyJump,
|
||||
HostOptions.Create(settled.Select(entry => new HostOption(entry.Key, entry.Value))),
|
||||
warnings);
|
||||
}
|
||||
|
||||
/// <summary>Walks every block that applies to an alias, keeping the first value for each keyword.</summary>
|
||||
private static void Settle(
|
||||
string alias,
|
||||
SshConfigDocument document,
|
||||
Dictionary<string, string> settled,
|
||||
List<string> identityFiles,
|
||||
List<string> warnings)
|
||||
{
|
||||
var duplicates = new HashSet<string>(HostOption.NameComparer);
|
||||
|
||||
foreach (var block in document.Blocks.Where(block => block.Patterns.Any(pattern => Matches(pattern, alias))))
|
||||
{
|
||||
foreach (var directive in block.Directives)
|
||||
{
|
||||
// IdentityFile is the one keyword that legitimately repeats — ssh tries each in turn — so it
|
||||
// accumulates instead of settling, and is not reported as a duplicate.
|
||||
if (string.Equals(directive.Keyword, "IdentityFile", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
identityFiles.Add(ExpandHome(directive.Value));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!settled.TryAdd(directive.Keyword, directive.Value))
|
||||
{
|
||||
duplicates.Add(directive.Keyword);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var keyword in duplicates.Order(HostOption.NameComparer))
|
||||
{
|
||||
// HostOptions is unique by name and cannot hold a repeat, which is a stated M1 limitation whose
|
||||
// own remarks require the import path to surface it rather than quietly keep one. The first is
|
||||
// kept because that is what ssh would have used.
|
||||
warnings.Add($"{keyword} was set more than once; the first value was kept.");
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolvePort(Dictionary<string, string> settled, List<string> warnings)
|
||||
{
|
||||
if (Take(settled, "Port") is not { } portText)
|
||||
{
|
||||
return HostSecret.DefaultPort;
|
||||
}
|
||||
|
||||
if (int.TryParse(portText, CultureInfo.InvariantCulture, out var parsed) && parsed is > 0 and <= 65535)
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
warnings.Add($"Port '{portText}' is not a usable port number; 22 was used.");
|
||||
|
||||
return HostSecret.DefaultPort;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Removed as it is read, so a keyword that maps onto a first-class field does not <em>also</em> end up
|
||||
/// in <c>Options</c>. A host carrying both a <c>Port</c> of 2222 and a <c>Port</c> directive saying 2222
|
||||
/// has two places to change it and one of them will be forgotten.
|
||||
/// </remarks>
|
||||
private static string? Take(Dictionary<string, string> settled, string keyword)
|
||||
{
|
||||
if (!settled.Remove(keyword, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
private static bool IsPattern(string name) => name.AsSpan().ContainsAny(PatternCharacters);
|
||||
|
||||
/// <summary>
|
||||
/// Whether a <c>Host</c> pattern applies to an alias.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>*</c> and <c>?</c> only. Negation is not implemented — a <c>!</c> pattern is treated as not
|
||||
/// matching, which errs towards importing a host with fewer defaults rather than towards silently
|
||||
/// applying a block the user had excluded.
|
||||
/// </remarks>
|
||||
private static bool Matches(string pattern, string alias)
|
||||
{
|
||||
if (pattern.StartsWith('!'))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return !pattern.AsSpan().ContainsAny(PatternCharacters)
|
||||
? string.Equals(pattern, alias, StringComparison.OrdinalIgnoreCase)
|
||||
: Glob(pattern.AsSpan(), alias.AsSpan());
|
||||
}
|
||||
|
||||
private static bool Glob(ReadOnlySpan<char> pattern, ReadOnlySpan<char> value)
|
||||
{
|
||||
if (pattern.IsEmpty)
|
||||
{
|
||||
return value.IsEmpty;
|
||||
}
|
||||
|
||||
if (pattern[0] == '*')
|
||||
{
|
||||
for (var skip = 0; skip <= value.Length; skip++)
|
||||
{
|
||||
if (Glob(pattern[1..], value[skip..]))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (value.IsEmpty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return (pattern[0] == '?' || char.ToUpperInvariant(pattern[0]) == char.ToUpperInvariant(value[0]))
|
||||
&& Glob(pattern[1..], value[1..]);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Tilde only. <c>%h</c>, <c>%p</c> and the rest are left alone: they are expanded per connection
|
||||
/// against values this importer does not have, and a path with a literal <c>%h</c> in it is at least
|
||||
/// visibly unexpanded rather than wrong.
|
||||
/// </remarks>
|
||||
private static string ExpandHome(string path)
|
||||
{
|
||||
if (!path.StartsWith("~/", StringComparison.Ordinal) && !path.StartsWith("~\\", StringComparison.Ordinal))
|
||||
{
|
||||
return path;
|
||||
}
|
||||
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
return Path.Combine(home, path[2..]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.137, )",
|
||||
"resolved": "3.0.137",
|
||||
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
S3-compatible buckets as a remote in the file browser.
|
||||
|
||||
Its own project rather than more of DodoSSH.Client.Transfer, because the two answer different
|
||||
questions — that one is about moving bytes and what to do when moving them stops halfway, this
|
||||
one is about one protocol's idea of what a file is — and because the AWS SDK belongs to exactly
|
||||
one project rather than to the whole client.
|
||||
|
||||
It references DodoSSH.Client.Ssh for two types: IRemoteFileStore and SftpEntry. That reads
|
||||
oddly and is deliberate; the reasoning is on IRemoteFileStore itself, and the short version is
|
||||
that moving them would rename a record the entire file browser is written against.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Domain/DodoSSH.Client.Domain.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AWSSDK.S3" />
|
||||
<PackageReference Include="AWSSDK.Core" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.ObjectStore.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace DodoSSH.Client.ObjectStore;
|
||||
|
||||
/// <summary>
|
||||
/// Translating between the paths a file browser uses and the keys a bucket has.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A bucket has no directories.</b> It has keys, which are strings, and a convention that <c>/</c> in a
|
||||
/// key means what it means in a path. Everything in this class is that convention written down in one place,
|
||||
/// because the alternative is the same three lines of trimming repeated at every call site with one of them
|
||||
/// subtly different.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The browser's side is an absolute POSIX path — <c>/reports/2026/q3.csv</c> — because that is what the
|
||||
/// screen, the breadcrumb trail and the transfer queue already speak. The bucket's side is a key with no
|
||||
/// leading slash: <c>reports/2026/q3.csv</c>. The root is <c>/</c> on one side and the empty string on the
|
||||
/// other, which is the case every one of these methods is really about.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class ObjectKeys
|
||||
{
|
||||
/// <summary>The path a file browser opens on.</summary>
|
||||
internal const string Root = "/";
|
||||
|
||||
/// <summary>The object key for a browser path.</summary>
|
||||
internal static string ToKey(string path) => path.TrimStart('/');
|
||||
|
||||
/// <summary>The browser path for an object key.</summary>
|
||||
internal static string ToPath(string key) => Root + key.TrimStart('/');
|
||||
|
||||
/// <summary>
|
||||
/// The prefix that lists one directory's immediate contents.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Trailing slash, always, and empty for the root. Without it a listing of <c>/reports</c> would also
|
||||
/// return <c>/reports-archive</c>, because a prefix match knows nothing about path segments.
|
||||
/// </remarks>
|
||||
internal static string ToPrefix(string path)
|
||||
{
|
||||
var key = ToKey(path);
|
||||
|
||||
return key.Length == 0 || key.EndsWith('/') ? key : key + "/";
|
||||
}
|
||||
|
||||
/// <summary>The last segment of a key, which is what a row shows.</summary>
|
||||
/// <remarks>
|
||||
/// Trailing slashes are removed first, so the common prefix <c>reports/2026/</c> yields <c>2026</c>
|
||||
/// rather than an empty string.
|
||||
/// </remarks>
|
||||
internal static string NameOf(string key)
|
||||
{
|
||||
var trimmed = key.TrimEnd('/');
|
||||
var slash = trimmed.LastIndexOf('/');
|
||||
|
||||
return slash < 0 ? trimmed : trimmed[(slash + 1)..];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Amazon;
|
||||
using Amazon.Runtime;
|
||||
using Amazon.S3;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.ObjectStore;
|
||||
|
||||
/// <summary>Opens a bucket as a place with files in it.</summary>
|
||||
/// <remarks>
|
||||
/// An interface so the file screen can be tested without a bucket, exactly as <c>ISftpSessionFactory</c> is
|
||||
/// what lets it be tested without a host.
|
||||
/// </remarks>
|
||||
public interface IObjectStoreFactory
|
||||
{
|
||||
/// <summary>Builds a client for one bucket.</summary>
|
||||
/// <param name="store">The bucket and its credentials, decrypted.</param>
|
||||
/// <remarks>
|
||||
/// Synchronous and cheap: nothing is contacted here. S3 is request-per-operation, so there is no
|
||||
/// connect step to fail — the first thing that can fail is the first listing, which is where the
|
||||
/// credentials and the endpoint are actually tested.
|
||||
/// </remarks>
|
||||
IRemoteFileStore Open(ObjectStoreSecret store);
|
||||
}
|
||||
|
||||
/// <summary>Opens buckets with the AWS SDK.</summary>
|
||||
public sealed class S3ObjectStoreFactory : IObjectStoreFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public IRemoteFileStore Open(ObjectStoreSecret store)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
|
||||
if (!store.TryValidate(out var reason))
|
||||
{
|
||||
throw new ArgumentException(reason, nameof(store));
|
||||
}
|
||||
|
||||
var config = new AmazonS3Config
|
||||
{
|
||||
// On for nearly every self-hosted service and off for AWS. It is a stored setting rather than
|
||||
// something inferred from the endpoint, because inferring it wrongly produces a DNS failure that
|
||||
// says nothing about buckets — see ObjectStoreSecret.UsePathStyle.
|
||||
ForcePathStyle = store.UsePathStyle,
|
||||
};
|
||||
|
||||
if (store.Endpoint is { } endpoint)
|
||||
{
|
||||
config.ServiceURL = endpoint;
|
||||
|
||||
// Still set when there is one, because SigV4 signs the region into every request and several
|
||||
// S3-compatible services check it. The ones that do not, ignore it.
|
||||
if (store.Region is { } named)
|
||||
{
|
||||
config.AuthenticationRegion = named;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// No endpoint means Amazon, and then the region is what resolves the host. Validation has
|
||||
// already refused the case where neither is set.
|
||||
config.RegionEndpoint = RegionEndpoint.GetBySystemName(store.Region!);
|
||||
}
|
||||
|
||||
var credentials = new BasicAWSCredentials(store.AccessKeyId, store.SecretAccessKey);
|
||||
|
||||
return new S3FileStore(new AmazonS3Client(credentials, config), store.Bucket);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Model;
|
||||
using Amazon.S3.Transfer;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.ObjectStore;
|
||||
|
||||
/// <summary>
|
||||
/// One S3-compatible bucket, as a place with files in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A bucket is not a filesystem, and the three places that matter are documented on the members rather
|
||||
/// than smoothed over.</b> There are no directories, only keys with slashes in them; an object cannot be
|
||||
/// appended to, so an interrupted upload cannot resume; and there is no rename, only copy-then-delete. Each
|
||||
/// is refused with a reason or implemented with its cost stated, because a file browser that quietly did
|
||||
/// something adjacent would be worse than one that said no.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Listings are one page.</b> <c>ListObjectsV2</c> returns up to a thousand keys and this asks for one
|
||||
/// page, so a prefix with more than that in it is shown truncated — which the screen says out loud. Paging
|
||||
/// the whole way through a bucket with a million objects under one prefix is a request storm behind a
|
||||
/// scrollbar nobody asked for; the filter box is the answer, and a prefix that large is not a directory
|
||||
/// anybody browses.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class S3FileStore : IRemoteFileStore
|
||||
{
|
||||
/// <summary>
|
||||
/// The most keys one listing asks for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The service's own maximum. Asking for less would page more often for no benefit; asking for more is
|
||||
/// not possible.
|
||||
/// </remarks>
|
||||
private const int PageSize = 1000;
|
||||
|
||||
private readonly IAmazonS3 client;
|
||||
private readonly string bucket;
|
||||
private int disposed;
|
||||
|
||||
internal S3FileStore(IAmazonS3 client, string bucket)
|
||||
{
|
||||
this.client = client;
|
||||
this.bucket = bucket;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Always true, because there is no connection to be up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// S3 is request-per-operation over HTTPS; there is no session to drop and nothing to poll. Answering
|
||||
/// false when the network is down would be a claim this type cannot make without a request of its own,
|
||||
/// and every operation already reports its own failure.
|
||||
/// </remarks>
|
||||
public bool IsConnected => Volatile.Read(ref disposed) == 0;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory => ObjectKeys.Root;
|
||||
|
||||
/// <summary>
|
||||
/// Lists one prefix: its immediate sub-prefixes as directories, its immediate keys as files.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The delimiter is what makes this a directory listing rather than a recursive walk — without it, a
|
||||
/// listing of the root returns every object in the bucket. Common prefixes come back as directories;
|
||||
/// the marker object some tools write for a "folder" is dropped, because it is the directory itself and
|
||||
/// showing it would put an empty-named row inside every one.
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
var prefix = ObjectKeys.ToPrefix(path);
|
||||
|
||||
ListObjectsV2Response response;
|
||||
try
|
||||
{
|
||||
response = await client.ListObjectsV2Async(
|
||||
new ListObjectsV2Request
|
||||
{
|
||||
BucketName = bucket,
|
||||
Prefix = prefix,
|
||||
Delimiter = "/",
|
||||
MaxKeys = PageSize,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AmazonS3Exception exception)
|
||||
{
|
||||
throw new SftpPathException(path, Describe(exception), exception);
|
||||
}
|
||||
|
||||
return Project(response, prefix);
|
||||
}
|
||||
|
||||
/// <summary>Turns one listing into rows a file browser can show.</summary>
|
||||
/// <remarks>
|
||||
/// Directories first and then by name, which is the order every caller of this interface expects and
|
||||
/// what saves the screen sorting it again.
|
||||
/// </remarks>
|
||||
private static IReadOnlyList<SftpEntry> Project(ListObjectsV2Response response, string prefix)
|
||||
{
|
||||
var entries = new List<SftpEntry>();
|
||||
|
||||
foreach (var common in response.CommonPrefixes ?? [])
|
||||
{
|
||||
entries.Add(new SftpEntry(
|
||||
ObjectKeys.NameOf(common),
|
||||
ObjectKeys.ToPath(common),
|
||||
SftpEntryKind.Directory,
|
||||
Length: 0,
|
||||
LastWriteTimeUtc: default,
|
||||
|
||||
// Blank rather than invented. A bucket has no POSIX mode, and printing drwxr-xr-x beside a
|
||||
// prefix would be a fact this store made up.
|
||||
Permissions: string.Empty));
|
||||
}
|
||||
|
||||
foreach (var item in response.S3Objects ?? [])
|
||||
{
|
||||
// The marker object for this prefix itself, which several tools write to make a folder appear
|
||||
// in a web console. It is this directory, not something in it.
|
||||
if (string.Equals(item.Key, prefix, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(new SftpEntry(
|
||||
ObjectKeys.NameOf(item.Key),
|
||||
ObjectKeys.ToPath(item.Key),
|
||||
SftpEntryKind.File,
|
||||
item.Size ?? 0,
|
||||
Utc(item.LastModified),
|
||||
Permissions: string.Empty));
|
||||
}
|
||||
|
||||
return
|
||||
[
|
||||
.. entries
|
||||
.OrderByDescending(entry => entry.Kind is SftpEntryKind.Directory)
|
||||
.ThenBy(entry => entry.Name, StringComparer.OrdinalIgnoreCase),
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The SDK's timestamp as an unambiguous instant.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Stated rather than converted implicitly. S3 returns <c>Last-Modified</c> in UTC and the SDK hands it
|
||||
/// over as a <see cref="DateTime"/> whose <c>Kind</c> is not reliably set — so an implicit conversion
|
||||
/// would read it as local time on some paths and shift every timestamp in the listing by the machine's
|
||||
/// offset. The file browser shows this column beside an SFTP one.
|
||||
/// </remarks>
|
||||
private static DateTimeOffset Utc(DateTime? moment) =>
|
||||
moment is { } value
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
|
||||
: default;
|
||||
|
||||
/// <summary>
|
||||
/// What one path is, or null when nothing is there.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two requests in the worst case, because a bucket cannot answer "is this a directory" directly: a
|
||||
/// HEAD tells us whether an object with that exact key exists, and only a listing can tell us whether
|
||||
/// anything lives under it as a prefix. The order matters — a key can be both, and the object is the
|
||||
/// more specific answer.
|
||||
/// </remarks>
|
||||
public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
var key = ObjectKeys.ToKey(path);
|
||||
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return new SftpEntry(
|
||||
string.Empty, ObjectKeys.Root, SftpEntryKind.Directory, 0, default, string.Empty);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var head = await client.GetObjectMetadataAsync(
|
||||
new GetObjectMetadataRequest { BucketName = bucket, Key = key },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new SftpEntry(
|
||||
ObjectKeys.NameOf(key),
|
||||
ObjectKeys.ToPath(key),
|
||||
SftpEntryKind.File,
|
||||
head.ContentLength,
|
||||
Utc(head.LastModified),
|
||||
Permissions: string.Empty);
|
||||
}
|
||||
catch (AmazonS3Exception exception) when (exception.StatusCode == System.Net.HttpStatusCode.NotFound)
|
||||
{
|
||||
// Not an object. It may still be a prefix with things under it, which is what a browser means
|
||||
// by a directory.
|
||||
}
|
||||
|
||||
var listing = await client.ListObjectsV2Async(
|
||||
new ListObjectsV2Request
|
||||
{
|
||||
BucketName = bucket,
|
||||
Prefix = ObjectKeys.ToPrefix(path),
|
||||
MaxKeys = 1,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return listing.KeyCount > 0
|
||||
? new SftpEntry(
|
||||
ObjectKeys.NameOf(key),
|
||||
ObjectKeys.ToPath(key),
|
||||
SftpEntryKind.Directory,
|
||||
0,
|
||||
default,
|
||||
string.Empty)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(offset);
|
||||
|
||||
var request = new GetObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(path) };
|
||||
|
||||
if (offset > 0)
|
||||
{
|
||||
// A ranged GET, which is what makes an interrupted download resumable — and the one place where
|
||||
// a bucket is better at this than SFTP, because the range is part of the protocol rather than a
|
||||
// seek on an open handle.
|
||||
request.ByteRange = new ByteRange(offset, long.MaxValue);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var response = await client.GetObjectAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return response.ResponseStream;
|
||||
}
|
||||
catch (AmazonS3Exception exception)
|
||||
{
|
||||
throw new SftpPathException(path, Describe(exception), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens an object for writing, from the beginning.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A non-zero offset is refused, and this is the one capability a bucket genuinely does not have.</b>
|
||||
/// Objects are immutable: there is no append, and no way to write into the middle of one. Multipart
|
||||
/// upload can rebuild an interrupted transfer, but only by keeping the upload id and every part's ETag
|
||||
/// across the interruption — state this store would have to persist somewhere, on behalf of a queue that
|
||||
/// already has its own idea of what resuming means. Refusing with a reason is the honest answer;
|
||||
/// silently starting from zero would corrupt a resumed file.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The returned stream is the writing half of a pipe. A background upload reads the other half and
|
||||
/// chunks it into parts, so a large file never lands on disk twice and memory stays bounded by the part
|
||||
/// size — which is what the alternative, buffering to a temporary file and putting it afterwards, would
|
||||
/// have cost.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
if (offset != 0)
|
||||
{
|
||||
throw new SftpPathException(
|
||||
path,
|
||||
"An object cannot be written to from the middle, so an interrupted upload to a bucket "
|
||||
+ "starts again rather than resuming.");
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream>(
|
||||
new S3UploadStream(client, bucket, ObjectKeys.ToKey(path), cancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the marker object that makes an empty prefix visible.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A zero-byte object whose key ends in <c>/</c>, which is the convention every S3 console and most
|
||||
/// tools use. It is not a directory — nothing in the service knows what one is — and it disappears by
|
||||
/// itself once real objects live under the prefix, which is why the listing above drops it.
|
||||
/// </remarks>
|
||||
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
var prefix = ObjectKeys.ToPrefix(path);
|
||||
|
||||
if (prefix.Length == 0)
|
||||
{
|
||||
throw new SftpPathException(path, "The root of a bucket already exists.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await client.PutObjectAsync(
|
||||
new PutObjectRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = prefix,
|
||||
ContentBody = string.Empty,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AmazonS3Exception exception)
|
||||
{
|
||||
throw new SftpPathException(path, Describe(exception), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes one object, or an empty prefix's marker.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not recursive, matching SFTP's own rule and for the same reason: a recursive delete
|
||||
/// against a bucket is the one operation on this screen that can destroy something no undo reaches. A
|
||||
/// prefix with anything under it is refused and says so.
|
||||
/// </remarks>
|
||||
public async Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(path);
|
||||
|
||||
var key = ObjectKeys.ToKey(path);
|
||||
|
||||
if (key.Length == 0)
|
||||
{
|
||||
throw new SftpPathException(path, "A bucket cannot delete its own root.");
|
||||
}
|
||||
|
||||
if (await StatAsync(path, cancellationToken).ConfigureAwait(false) is { Kind: SftpEntryKind.Directory })
|
||||
{
|
||||
var listing = await client.ListObjectsV2Async(
|
||||
new ListObjectsV2Request
|
||||
{
|
||||
BucketName = bucket,
|
||||
Prefix = ObjectKeys.ToPrefix(path),
|
||||
MaxKeys = 2,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// One key is the marker object for this prefix itself; anything more is contents.
|
||||
if (listing.KeyCount > 1)
|
||||
{
|
||||
throw new SftpPathException(
|
||||
path, "There are still objects under this prefix, so it was not deleted.");
|
||||
}
|
||||
|
||||
key = ObjectKeys.ToPrefix(path);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await client.DeleteObjectAsync(
|
||||
new DeleteObjectRequest { BucketName = bucket, Key = key },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AmazonS3Exception exception)
|
||||
{
|
||||
throw new SftpPathException(path, Describe(exception), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies to the new key and deletes the old one, which is what a bucket has instead of rename.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Not atomic, and it cannot be. Between the two requests both keys exist; if the delete fails, both
|
||||
/// still do. The copy is server-side — no bytes come to this machine — so the window is short, but it is
|
||||
/// real and a failure leaves a duplicate rather than a loss, which is the safe direction.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only objects. Renaming a prefix means copying every key under it, which is a bulk operation wearing
|
||||
/// a rename's clothing, and the failure mode is a half-moved directory.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(fromPath);
|
||||
ArgumentNullException.ThrowIfNull(toPath);
|
||||
|
||||
if (await StatAsync(fromPath, cancellationToken).ConfigureAwait(false)
|
||||
is not { Kind: SftpEntryKind.File })
|
||||
{
|
||||
throw new SftpPathException(
|
||||
fromPath,
|
||||
"Only an object can be renamed in a bucket. A prefix would have to be copied key by key.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await client.CopyObjectAsync(
|
||||
new CopyObjectRequest
|
||||
{
|
||||
SourceBucket = bucket,
|
||||
SourceKey = ObjectKeys.ToKey(fromPath),
|
||||
DestinationBucket = bucket,
|
||||
DestinationKey = ObjectKeys.ToKey(toPath),
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await client.DeleteObjectAsync(
|
||||
new DeleteObjectRequest { BucketName = bucket, Key = ObjectKeys.ToKey(fromPath) },
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (AmazonS3Exception exception)
|
||||
{
|
||||
throw new SftpPathException(fromPath, Describe(exception), exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 0)
|
||||
{
|
||||
client.Dispose();
|
||||
}
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What went wrong, in words that name the bucket rather than the protocol.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The SDK's own messages are accurate and unhelpful at a file browser: "The specified key does not
|
||||
/// exist" is fine, and "Access Denied" against a bucket somebody has just typed the keys for is the
|
||||
/// moment to say which of the two is more likely.
|
||||
/// </remarks>
|
||||
private static string Describe(AmazonS3Exception exception) => exception.StatusCode switch
|
||||
{
|
||||
System.Net.HttpStatusCode.NotFound => "There is nothing at that key.",
|
||||
System.Net.HttpStatusCode.Forbidden =>
|
||||
"The bucket refused that. Check the access key and what it is allowed to do.",
|
||||
System.Net.HttpStatusCode.BadRequest when exception.ErrorCode is "AuthorizationHeaderMalformed" =>
|
||||
"The bucket is in a different region to the one configured.",
|
||||
_ => exception.Message,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using System.IO.Pipelines;
|
||||
using Amazon.S3;
|
||||
using Amazon.S3.Transfer;
|
||||
|
||||
namespace DodoSSH.Client.ObjectStore;
|
||||
|
||||
/// <summary>
|
||||
/// A stream you write an object into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The direction is the whole problem.</b> The transfer queue asks for somewhere to write and then copies
|
||||
/// a local file into it; the S3 SDK wants a stream it can read from. Something has to bridge the two, and
|
||||
/// there are only three ways to do it: buffer the whole object to a temporary file and upload afterwards
|
||||
/// (correct, and doubles the disk a big upload costs), hold it in memory (correct until somebody uploads a
|
||||
/// disc image), or run the upload concurrently and hand back the writing half of a pipe.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is the third. <see cref="TransferUtility"/> reads the pipe and splits it into multipart chunks, so
|
||||
/// memory stays bounded by the part size however large the object is, and nothing lands on disk twice.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Completion is on <see cref="DisposeAsync"/>, and it is not optional.</b> The upload is only finished
|
||||
/// when the pipe is completed and the background task has been awaited — so a caller that abandons this
|
||||
/// stream without disposing it leaves an upload running against a bucket. That is the same contract every
|
||||
/// stream has; it is written down because the consequence here is remote rather than local.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A failed upload has to surface at the writer.</b> If the service refuses halfway, the reading half
|
||||
/// stops and this stream's next <c>WriteAsync</c> would otherwise block for ever — so the background task's
|
||||
/// completion also completes the pipe's reader with the exception, which is what makes the write throw with
|
||||
/// the real reason rather than hang.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class S3UploadStream : Stream
|
||||
{
|
||||
private readonly Pipe pipe = new();
|
||||
private readonly Task upload;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
private int disposed;
|
||||
|
||||
internal S3UploadStream(
|
||||
IAmazonS3 client,
|
||||
string bucket,
|
||||
string key,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this.cancellationToken = cancellationToken;
|
||||
|
||||
upload = UploadAsync(client, bucket, key);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanRead => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanSeek => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool CanWrite => Volatile.Read(ref disposed) == 0;
|
||||
|
||||
/// <summary>Not answerable: an object's length is not known until it has all been written.</summary>
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc cref="Length" />
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask WriteAsync(
|
||||
ReadOnlyMemory<byte> buffer,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await pipe.Writer.WriteAsync(buffer, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (result.IsCompleted)
|
||||
{
|
||||
// The reader has stopped, which means the upload ended — almost always because the service
|
||||
// refused it. Awaiting the task surfaces that exception here, at the write, instead of leaving
|
||||
// the caller to discover it at disposal after copying a whole file into nothing.
|
||||
await upload.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refused: this stream is asynchronous all the way down.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Blocking on the pipe from a synchronous write is a deadlock waiting for a thread-pool starvation to
|
||||
/// find it — the other half of the pipe is being read by a task that needs a thread to run on. The only
|
||||
/// caller is the transfer queue, which copies asynchronously, so this is unreachable rather than
|
||||
/// inconvenient. Throwing says which; blocking would say nothing until a large upload hung.
|
||||
/// </remarks>
|
||||
public override void Write(byte[] buffer, int offset, int count) =>
|
||||
throw new NotSupportedException(
|
||||
"An upload to a bucket is written asynchronously; use WriteAsync.");
|
||||
|
||||
/// <summary>
|
||||
/// Nothing, deliberately.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A flush cannot mean what a caller would want it to here — the object does not exist until the upload
|
||||
/// completes, so there is no partial state to make durable. The pipe's own writes are already handed to
|
||||
/// the reader as they arrive.
|
||||
/// </remarks>
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="Flush" />
|
||||
public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int Read(byte[] buffer, int offset, int count) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
|
||||
/// <inheritdoc />
|
||||
public override async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Completing the writer is what tells the upload there is no more, so it must happen before the
|
||||
// await — and it must happen even when the caller is abandoning a failed transfer, or the background
|
||||
// task never ends.
|
||||
await pipe.Writer.CompleteAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await upload.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await base.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refused when it would have to finish an upload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Completing this stream means completing the pipe and awaiting the upload, and doing that from a
|
||||
/// synchronous <c>Dispose</c> is the deadlock the synchronous <c>Write</c> above avoids. The alternative
|
||||
/// — completing the writer and abandoning the task — silently drops whatever the service was about to
|
||||
/// say, including a refusal, and reports a transfer as finished that never landed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So a <c>using</c> rather than an <c>await using</c> throws, which is loud, immediate and correct. The
|
||||
/// only caller already uses <c>await using</c>; this is what stops a second one being written by
|
||||
/// accident.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && Volatile.Read(ref disposed) == 0)
|
||||
{
|
||||
throw new NotSupportedException(
|
||||
"An upload to a bucket finishes asynchronously; use await using rather than using.");
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
private async Task UploadAsync(IAmazonS3 client, string bucket, string key)
|
||||
{
|
||||
using var transfer = new TransferUtility(client);
|
||||
|
||||
try
|
||||
{
|
||||
await transfer.UploadAsync(
|
||||
new TransferUtilityUploadRequest
|
||||
{
|
||||
BucketName = bucket,
|
||||
Key = key,
|
||||
InputStream = pipe.Reader.AsStream(),
|
||||
|
||||
// The stream has no length, so the utility has to be told not to look for one. It reads
|
||||
// until the pipe completes and splits what it read into parts.
|
||||
AutoCloseStream = false,
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await pipe.Reader.CompleteAsync().ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Completing the reader *with* the exception is what unblocks a writer that is still copying:
|
||||
// its next write sees a completed pipe and awaits this task, which rethrows this.
|
||||
await pipe.Reader.CompleteAsync(exception).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"AWSSDK.Core": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "Direct",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.137, )",
|
||||
"resolved": "3.0.137",
|
||||
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.3",
|
||||
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
using System.Threading.Channels;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Records keychain changes into the vault they happened in, without making the save wait.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="ConnectionRecorder"/>'s shape, and the reason is the same one stated a different way: the
|
||||
/// caller is a Save the user is watching, and an encrypt-and-write on that path would put the log's cost
|
||||
/// into every edit. So <see cref="Record"/> posts to a bounded channel and returns, and one background task
|
||||
/// does the work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Session-scoped, unlike the connection recorder.</b> This one is created with the vault and dies with
|
||||
/// it — there is no equivalent of a shell that outlives a lock, because an edit is finished by the time it
|
||||
/// is recorded. That is why it is owned by <see cref="VaultSession"/> rather than by the shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Every failure is swallowed.</b> A log write that failed and surfaced would fail a save, and the whole
|
||||
/// premise of the outbox is that saving works offline and cannot be refused. What is lost when this drops
|
||||
/// something is one advisory line.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ActivityRecorder : IActivityLogSink, IAsyncDisposable
|
||||
{
|
||||
/// <inheritdoc cref="ConnectionRecorder" path="/remarks/para[4]" />
|
||||
private const int QueueDepth = 512;
|
||||
|
||||
private static readonly TimeSpan FlushTimeout = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly Channel<ActivityLogSecret> pending = Channel.CreateBounded<ActivityLogSecret>(
|
||||
new BoundedChannelOptions(QueueDepth)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
});
|
||||
|
||||
private readonly ActivityLogRepository log;
|
||||
private readonly Guid vaultId;
|
||||
private readonly Guid actorUserId;
|
||||
private readonly string deviceName;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
private readonly Task drain;
|
||||
|
||||
private int disposed;
|
||||
|
||||
/// <param name="log">Where entries go.</param>
|
||||
/// <param name="vaultId">The vault they belong to.</param>
|
||||
/// <param name="actorUserId">Which account is making them.</param>
|
||||
/// <param name="deviceName">What this machine calls itself.</param>
|
||||
/// <param name="clock">Time source.</param>
|
||||
internal ActivityRecorder(
|
||||
ActivityLogRepository log,
|
||||
Guid vaultId,
|
||||
Guid actorUserId,
|
||||
string deviceName,
|
||||
TimeProvider clock)
|
||||
{
|
||||
this.log = log;
|
||||
this.vaultId = vaultId;
|
||||
this.actorUserId = actorUserId;
|
||||
this.deviceName = deviceName;
|
||||
this.clock = clock;
|
||||
|
||||
drain = DrainAsync(lifetime.Token);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Record(
|
||||
Guid vaultId,
|
||||
SyncEntityType kind,
|
||||
Guid entityId,
|
||||
string label,
|
||||
ActivityOperation operation,
|
||||
IReadOnlyList<string> changedFields)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(changedFields);
|
||||
|
||||
if (vaultId != this.vaultId)
|
||||
{
|
||||
// A write to a vault this recorder is not for. Not currently reachable — one session, one active
|
||||
// vault — and refused rather than filed under the wrong one, because that is the failure that
|
||||
// would be hardest to notice once shared vaults land.
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = new ActivityLogSecret
|
||||
{
|
||||
// The name rather than the number, so a build that has never heard of a kind still shows
|
||||
// something a person can read. See ActivityLogSecretCodec.
|
||||
ItemKind = Enum.GetName(kind) ?? kind.ToString(),
|
||||
ItemId = entityId,
|
||||
ItemLabel = label,
|
||||
Operation = operation,
|
||||
ChangedFields = string.Join(", ", changedFields),
|
||||
At = clock.GetUtcNow(),
|
||||
DeviceName = deviceName,
|
||||
ActorUserId = actorUserId,
|
||||
};
|
||||
|
||||
pending.Writer.TryWrite(entry);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (Interlocked.Exchange(ref disposed, 1) == 1)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pending.Writer.TryComplete();
|
||||
|
||||
try
|
||||
{
|
||||
await drain.WaitAsync(FlushTimeout).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
|
||||
{
|
||||
// Whatever is left goes unwritten, which is the same trade the queue's own DropOldest makes.
|
||||
}
|
||||
|
||||
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 async Task DrainAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var entry in pending.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await log.CreateAsync(vaultId, entry, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
// Swallowed. There is no caller left to tell, and the realistic failure is a cache that
|
||||
// has gone away underneath a session being disposed.
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Shutting down.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
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);
|
||||
}
|
||||
@@ -24,6 +24,14 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Storage/DodoSSH.Client.Storage.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Sync/DodoSSH.Client.Sync.csproj" />
|
||||
<!--
|
||||
The terminal layer, for one interface: IConnectionLogSink, which ConnectionRecorder implements. The
|
||||
direction is the point. Client.Terminal references only Client.Ssh and must keep doing so — a workspace
|
||||
that knew about vaults would be a workspace that could not keep a shell running through a lock — so the
|
||||
hole is declared down there and filled up here, exactly as VaultKnownHostStore fills IKnownHostStore.
|
||||
Nothing in Client.Terminal references this project, so the graph stays acyclic.
|
||||
-->
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>How much log a vault keeps.</summary>
|
||||
/// <param name="MaxAge">How far back entries are kept.</param>
|
||||
/// <param name="MaxEntries">How many entries of each kind are kept, whatever their age.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two limits rather than one, and whichever bites first wins. An age alone lets somebody who connects two
|
||||
/// hundred times a day accumulate a log nobody wants to sync; a count alone means a quiet month of work
|
||||
/// disappears the week somebody has a busy afternoon.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Retention is not optional here the way it is for a local log file.</b> These entries sync, so keeping
|
||||
/// them for ever costs every machine in the vault the bandwidth and the storage — which is the price of the
|
||||
/// decision that made them auditable in the first place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record LogRetention(TimeSpan MaxAge, int MaxEntries)
|
||||
{
|
||||
/// <summary>Ninety days, or five thousand entries of each kind.</summary>
|
||||
public static LogRetention Default { get; } = new(TimeSpan.FromDays(90), 5_000);
|
||||
}
|
||||
|
||||
/// <summary>What one pruning pass removed.</summary>
|
||||
/// <param name="Connections">Connection entries deleted.</param>
|
||||
/// <param name="Activity">Activity entries deleted.</param>
|
||||
public sealed record LogPruneResult(int Connections, int Activity)
|
||||
{
|
||||
/// <summary>Whether anything went.</summary>
|
||||
public bool RemovedAnything => Connections > 0 || Activity > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes log entries a vault has agreed to stop keeping.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A real tombstone delete that pushes</b>, because these are synced items — so pruning is not a local
|
||||
/// tidy-up and cannot be run on a whim. It goes once when a vault opens and at most once per auto-sync tick
|
||||
/// behind a last-pruned stamp; the alternative, a timer of its own, would be a second thing waking a laptop
|
||||
/// up to write to a server.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Age is read from the entry, not from the item.</b> A connection entry knows when the connection
|
||||
/// started and an activity entry knows when the change happened, and both are the times a person means. The
|
||||
/// item id's own v7 timestamp is close but not the same — it is when the entry was <em>written</em>, which
|
||||
/// for a connection is when it ended.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LogPruner
|
||||
{
|
||||
/// <summary>Deletes whatever falls outside the retention policy.</summary>
|
||||
/// <param name="session">The open vault.</param>
|
||||
/// <param name="retention">What to keep.</param>
|
||||
/// <param name="now">The moment to measure age from.</param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <remarks>
|
||||
/// Reads both logs in full, which is what makes the count limit possible at all: neither the server nor
|
||||
/// the local mirror can order encrypted entries, so the only place that can decide which five thousand
|
||||
/// to keep is a client that has decrypted them.
|
||||
/// </remarks>
|
||||
public static async Task<LogPruneResult> PruneAsync(
|
||||
VaultSession session,
|
||||
LogRetention retention,
|
||||
DateTimeOffset now,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(session);
|
||||
ArgumentNullException.ThrowIfNull(retention);
|
||||
|
||||
var connections = await session.ConnectionLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var activity = await session.ActivityLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var cutoff = now - retention.MaxAge;
|
||||
|
||||
var staleConnections = Stale(
|
||||
connections.Items, retention, cutoff, entry => entry.Secret.StartedAt);
|
||||
|
||||
var staleActivity = Stale(activity.Items, retention, cutoff, entry => entry.Secret.At);
|
||||
|
||||
foreach (var entry in staleConnections)
|
||||
{
|
||||
await session.ConnectionLog
|
||||
.DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
foreach (var entry in staleActivity)
|
||||
{
|
||||
await session.ActivityLog
|
||||
.DeleteAsync(session.ActiveVaultId, entry, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return new LogPruneResult(staleConnections.Count, staleActivity.Count);
|
||||
}
|
||||
|
||||
/// <summary>The ids of the entries that fall outside the policy, newest kept.</summary>
|
||||
private static IReadOnlyList<Guid> Stale<TSecret>(
|
||||
IReadOnlyList<VaultItem<TSecret>> entries,
|
||||
LogRetention retention,
|
||||
DateTimeOffset cutoff,
|
||||
Func<VaultItem<TSecret>, DateTimeOffset> at)
|
||||
where TSecret : class, IVaultSecret
|
||||
{
|
||||
var ordered = entries.OrderByDescending(at).ToArray();
|
||||
|
||||
return
|
||||
[
|
||||
.. ordered
|
||||
.Where((entry, index) => index >= retention.MaxEntries || at(entry) < cutoff)
|
||||
.Select(entry => entry.EntityId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,22 @@ public interface IVaultServer : IDisposable
|
||||
/// <summary>Pull and push.</summary>
|
||||
ISyncApi Sync { get; }
|
||||
|
||||
/// <summary>Teams, their members, and the vaults they own.</summary>
|
||||
ITeamApi Teams { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The public-key directory, and the key log that makes an answer from it checkable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed as one member because the two are only ever used together: a directory answer is a claim
|
||||
/// the server makes about somebody else's key, and the log is what turns it into something a client
|
||||
/// can verify. See <c>KeyLogAudit</c>.
|
||||
/// </remarks>
|
||||
IDirectoryApi Directory { get; }
|
||||
|
||||
/// <summary>Vault key grants: who can open a vault, and who let them.</summary>
|
||||
IVaultGrantApi Grants { get; }
|
||||
|
||||
/// <summary>Obtains the identity provider's signature over a key statement.</summary>
|
||||
IKeyBindingAuthorizer KeyBinding { get; }
|
||||
|
||||
@@ -213,6 +229,15 @@ public sealed class ServerConnection : IVaultServer
|
||||
/// <inheritdoc />
|
||||
public ISyncApi Sync => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ITeamApi Teams => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IDirectoryApi Directory => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IVaultGrantApi Grants => Api;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IKeyBindingAuthorizer KeyBinding => Oidc;
|
||||
|
||||
|
||||
@@ -26,6 +26,25 @@ public sealed record ConflictNotice(
|
||||
IReadOnlyList<ConflictDetailEntry> Fields,
|
||||
DateTimeOffset DetectedAt);
|
||||
|
||||
/// <summary>One vault's outcome from a pass over all of them.</summary>
|
||||
/// <param name="VaultId">The vault.</param>
|
||||
/// <param name="Name">Its display name, so a message about it can name it.</param>
|
||||
/// <param name="Report">What the pass did, when it completed.</param>
|
||||
/// <param name="Failure">
|
||||
/// Why it did not, when it failed. Carried rather than thrown so one unreachable team vault cannot
|
||||
/// leave the others unsynced — and reported rather than swallowed, because a vault that silently
|
||||
/// stopped syncing is the worst of the three outcomes.
|
||||
/// </param>
|
||||
public sealed record VaultSyncReport(
|
||||
Guid VaultId,
|
||||
string Name,
|
||||
SyncReport? Report,
|
||||
Exception? Failure)
|
||||
{
|
||||
/// <summary>Whether this vault synced.</summary>
|
||||
public bool Succeeded => Report is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An unlocked vault: the keys are in memory, the cache is open, and the hosts are readable.
|
||||
/// </summary>
|
||||
@@ -41,13 +60,23 @@ public sealed record ConflictNotice(
|
||||
/// perfectly usable with no network at all and syncing is the occasional thing that needs one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class VaultSession : IAsyncDisposable
|
||||
public sealed partial class VaultSession : IAsyncDisposable
|
||||
{
|
||||
private readonly UserSecretBundle bundle;
|
||||
private readonly LocalCacheProtector protector;
|
||||
private readonly VaultKeyring keyring;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly SyncOptions options;
|
||||
|
||||
/// <summary>
|
||||
/// Records what is done to this vault's items, for as long as this session lasts.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Owned here rather than by the shell, unlike the connection recorder beside it. An edit is finished by
|
||||
/// the time it is recorded, so nothing about it can outlive the session — where a shell genuinely can.
|
||||
/// </remarks>
|
||||
private readonly ActivityRecorder activity;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
internal VaultSession(
|
||||
@@ -78,21 +107,51 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
Vault = new VaultStore(caches, clock);
|
||||
Unlock = new UnlockStore(caches, clock);
|
||||
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
|
||||
Hosts = new HostRepository(Items, Outbox, keyring);
|
||||
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
|
||||
Credentials = new CredentialRepository(Items, Outbox, keyring);
|
||||
KnownHosts = new KnownHostRepository(Items, Outbox, keyring);
|
||||
// The two log repositories first, and unaudited: the recorder writes through one of them, so a log
|
||||
// that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
|
||||
// actually stops it; building them first is what lets the recorder exist before the kinds that use
|
||||
// it. See ActivityRecorder.
|
||||
ConnectionLog = new ConnectionLogRepository(Items, Outbox, keyring);
|
||||
ActivityLog = new ActivityLogRepository(Items, Outbox, keyring);
|
||||
|
||||
activity = new ActivityRecorder(
|
||||
ActivityLog, activeVaultId, profile.UserId, Environment.MachineName, clock);
|
||||
|
||||
Hosts = new HostRepository(Items, Outbox, keyring, activity);
|
||||
SshKeys = new SshKeyRepository(Items, Outbox, keyring, activity);
|
||||
Credentials = new CredentialRepository(Items, Outbox, keyring, activity);
|
||||
KnownHosts = new KnownHostRepository(Items, Outbox, keyring, activity);
|
||||
HostGroups = new HostGroupRepository(Items, Outbox, keyring, activity);
|
||||
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
|
||||
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
|
||||
}
|
||||
|
||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||
public StoredUnlockMaterial Profile { get; }
|
||||
|
||||
/// <summary>Every vault this user can reach, readable or not.</summary>
|
||||
public IReadOnlyList<StoredVault> Vaults { get; }
|
||||
/// <remarks>
|
||||
/// Re-read rather than fixed at unlock: a vault a teammate shares arrives mid-session, and one
|
||||
/// whose grant is withdrawn stops being readable mid-session too. <see cref="RefreshVaultsAsync"/>
|
||||
/// is what moves it, and it is the only thing that does.
|
||||
/// </remarks>
|
||||
public IReadOnlyList<StoredVault> Vaults { get; private set; }
|
||||
|
||||
/// <summary>The vault the interface is showing. The personal one, for now.</summary>
|
||||
/// <summary>
|
||||
/// The vault new items are created in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One vault is the write target, not the read set — reading spans every vault the keyring opened.
|
||||
/// It stays the first readable one, which is the personal vault whenever there is one, because an
|
||||
/// application that silently filed a new host into a team's vault because that was the last thing
|
||||
/// selected would be the wrong default in the one direction that is hard to undo.
|
||||
/// </remarks>
|
||||
public Guid ActiveVaultId { get; }
|
||||
|
||||
/// <summary>Every vault this session actually holds a key for.</summary>
|
||||
public IEnumerable<StoredVault> ReadableVaults =>
|
||||
Vaults.Where(vault => keyring.CanRead(vault.VaultId));
|
||||
|
||||
/// <summary>Hosts, decrypted, with unpushed local changes laid over them.</summary>
|
||||
public HostRepository Hosts { get; }
|
||||
|
||||
@@ -114,6 +173,36 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
public KnownHostRepository KnownHosts { get; }
|
||||
|
||||
/// <summary>The groups hosts are filed under, decrypted, with unpushed local changes laid over them.</summary>
|
||||
/// <remarks>
|
||||
/// Membership is not in here. Each host carries its own <c>GroupId</c>, so a group is only ever a name —
|
||||
/// which is what makes filing two hosts at once on two machines two independent writes rather than one
|
||||
/// contested one.
|
||||
/// </remarks>
|
||||
public HostGroupRepository HostGroups { get; }
|
||||
|
||||
/// <summary>Saved commands, decrypted, with unpushed local changes laid over them.</summary>
|
||||
public SnippetRepository Snippets { get; }
|
||||
|
||||
/// <summary>S3-compatible buckets and their credentials, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// Read when the file screen builds its picker, and the object-store client is constructed from the
|
||||
/// result. Nothing here is on a transfer's data path.
|
||||
/// </remarks>
|
||||
public ObjectStoreRepository ObjectStores { get; }
|
||||
|
||||
/// <summary>The connections this vault has recorded, decrypted.</summary>
|
||||
/// <remarks>
|
||||
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
|
||||
/// entry is created once, on the teardown path of a session, and encrypting on that thread is how
|
||||
/// closing the application comes to take four seconds — see that type for the queue that keeps the two
|
||||
/// apart.
|
||||
/// </remarks>
|
||||
public ConnectionLogRepository ConnectionLog { get; }
|
||||
|
||||
/// <summary>The keychain changes this vault has recorded, decrypted.</summary>
|
||||
public ActivityLogRepository ActivityLog { get; }
|
||||
|
||||
/// <summary>Vaults whose grant could not be opened, so their items cannot be read.</summary>
|
||||
public IReadOnlyList<Guid> UnreadableVaults => keyring.Unopened;
|
||||
|
||||
@@ -179,10 +268,14 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
return SignIn.ForgetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Runs one synchronisation pass over the active vault.</summary>
|
||||
/// <summary>Runs one synchronisation pass over one vault.</summary>
|
||||
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
|
||||
/// <param name="vaultId">The vault to sync.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task<SyncReport> SyncAsync(ISyncApi api, CancellationToken cancellationToken)
|
||||
public Task<SyncReport> SyncAsync(
|
||||
ISyncApi api,
|
||||
Guid vaultId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
@@ -190,7 +283,51 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
var engine = new SyncEngine(
|
||||
api, Items, Outbox, SyncState, Conflicts, keyring, clock, options);
|
||||
|
||||
return engine.SyncAsync(ActiveVaultId, cancellationToken);
|
||||
return engine.SyncAsync(vaultId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one synchronisation pass over every vault this session can read.
|
||||
/// </summary>
|
||||
/// <returns>One report per vault, in the order they were synced.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Sequential rather than concurrent. Each vault has its own cursor and its own outbox, so nothing
|
||||
/// forces the order — but a client that opened one connection per vault would multiply its request
|
||||
/// rate by the number of teams somebody is in, against a server the same person is also using
|
||||
/// interactively. Vaults are few and passes are cheap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A vault that throws does not stop the rest. One team's vault being unreachable — a revoked grant
|
||||
/// noticed mid-pass, a server-side fault — is not a reason to leave the personal vault unsynced,
|
||||
/// and the failure is reported per vault rather than as one exception naming none of them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<IReadOnlyList<VaultSyncReport>> SyncAllAsync(
|
||||
ISyncApi api,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
|
||||
var reports = new List<VaultSyncReport>();
|
||||
|
||||
foreach (var vault in ReadableVaults.ToList())
|
||||
{
|
||||
try
|
||||
{
|
||||
var report = await SyncAsync(api, vault.VaultId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, report, null));
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
reports.Add(new VaultSyncReport(vault.VaultId, vault.Name, null, exception));
|
||||
}
|
||||
}
|
||||
|
||||
return reports;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -316,7 +453,8 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
ArgumentNullException.ThrowIfNull(deviceKeys);
|
||||
|
||||
// Before any await that could yield, because on Windows this reaches a consent dialog and a dialog
|
||||
// needs the thread it was called from to be one that pumps messages. See WindowsDeviceKeyStore.
|
||||
// needs the thread it was called from to be one that pumps messages. See the desktop head's
|
||||
// WindowsDeviceKeyStore — this layer only knows it is handed an IDeviceKeyStore.
|
||||
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var stored = await Unlock.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -369,33 +507,53 @@ public sealed class VaultSession : IAsyncDisposable
|
||||
return Conflicts.AcknowledgeAsync(conflictId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>How many local changes are waiting to be pushed.</summary>
|
||||
/// <summary>
|
||||
/// How many local changes the <em>user</em> has made that are waiting to be pushed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Log entries are excluded, and the exclusion is the honest reading rather than a convenience.</b>
|
||||
/// This number is shown in the titlebar and it answers one question: how much of my work is not yet
|
||||
/// safe anywhere else. A connection that was recorded is not somebody's work — nobody typed it, nobody
|
||||
/// would re-enter it if this machine were lost, and an entry queued a moment after a save would leave
|
||||
/// the titlebar claiming an unsynced change immediately after reporting a successful sync.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The entries are still pushed, on the next pass like anything else. What they are kept out of is a
|
||||
/// count that means something narrower than "rows in the outbox".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<int> PendingChangeCountAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
var pending = await Outbox.ListAllAsync(ActiveVaultId, cancellationToken).ConfigureAwait(false);
|
||||
return pending.Count;
|
||||
|
||||
return pending.Count(operation => operation.EntityType is not (
|
||||
SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return ValueTask.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
// Before the keys go, and it waits — briefly. Anything queued has to be encrypted under a vault key
|
||||
// that is about to be zeroed, so a fire-and-forget here would silently lose the last few entries of
|
||||
// every session. The wait is bounded inside the recorder; locking never stalls on it.
|
||||
await activity.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
// Order is not important — none of these depend on another — but completeness is. Missing one
|
||||
// leaves key material in memory for the life of the process, which is the opposite of what
|
||||
// locking is supposed to mean.
|
||||
keyring.Dispose();
|
||||
protector.Dispose();
|
||||
bundle.Dispose();
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
private static ConflictNotice Describe(StoredConflict conflict)
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Security.Cryptography;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Sync;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Crypto;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>What a share attempt did.</summary>
|
||||
/// <param name="Shared">Whether a grant was recorded.</param>
|
||||
/// <param name="Verification">
|
||||
/// How the recipient's key was checked. Present whether or not the share went ahead, because a refusal
|
||||
/// is the interesting outcome and the reason for it is the whole of what a user needs to see.
|
||||
/// </param>
|
||||
/// <param name="Message">One line for a person. Never contains key material.</param>
|
||||
public sealed record ShareOutcome(
|
||||
bool Shared,
|
||||
RecipientVerification Verification,
|
||||
string Message);
|
||||
|
||||
/// <summary>
|
||||
/// Sharing, from the side that holds the keys.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These live on <see cref="VaultSession"/> rather than in a service above it for the reason
|
||||
/// registering a device does: wrapping a vault key is the one step only an unlocked session can
|
||||
/// perform, and this type is the keyring's custodian. Everything else — the calls, the directory —
|
||||
/// arrives as a parameter, so the session still knows nothing about how either is implemented.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here trusts the server's answer about somebody else's key.</b> Every share reads the
|
||||
/// whole key log, verifies its hash chain, and refuses unless the directory's answer appears in it
|
||||
/// unchanged. That check is the difference between end-to-end encryption and a server that can read
|
||||
/// everything by handing out a key of its own; see <see cref="KeyLogAudit"/> and ADR 0001.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class VaultSession
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a vault owned by a team, generating its key here.
|
||||
/// </summary>
|
||||
/// <param name="api">The team calls.</param>
|
||||
/// <param name="teamId">The owning team.</param>
|
||||
/// <param name="name">Display name. Plaintext, as all vault names are.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The new vault, already readable by this session.</returns>
|
||||
/// <remarks>
|
||||
/// The key never leaves this process in the clear: it is generated here, sealed to this user's own
|
||||
/// encryption key, and the seal is what the server stores. The creator's grant carries no key log
|
||||
/// head, exactly as a personal vault's does not — there is no third party whose key could have been
|
||||
/// substituted when you wrap something to yourself.
|
||||
/// </remarks>
|
||||
public async Task<StoredVault> CreateTeamVaultAsync(
|
||||
ITeamApi api,
|
||||
Guid teamId,
|
||||
string name,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
|
||||
var vaultId = Guid.CreateVersion7();
|
||||
var vaultKey = VaultKeys.Create();
|
||||
var now = clock.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
var request = BuildCreateRequest(vaultId, vaultKey, name, now);
|
||||
|
||||
var summary = await api.CreateTeamVaultAsync(teamId, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var stored = ToStored(summary);
|
||||
|
||||
await Vault.UpsertAsync(stored, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Adopted rather than unwrapped from the response: this process generated the key, so
|
||||
// unwrapping the server's copy of our own seal would be a round trip to learn something we
|
||||
// already know. The keyring takes ownership from here.
|
||||
keyring.Adopt(vaultId, vaultKey, summary.KeyGeneration);
|
||||
|
||||
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return stored;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Never reached the keyring, so this is the only thing that can release it.
|
||||
CryptographicOperations.ZeroMemory(vaultKey);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a vault's key to another member, after verifying their published key.
|
||||
/// </summary>
|
||||
/// <param name="grants">The grant calls.</param>
|
||||
/// <param name="directory">The directory and the key log that makes it checkable.</param>
|
||||
/// <param name="vaultId">The vault to share.</param>
|
||||
/// <param name="recipientUserId">Who to share it with.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The verification is not optional and is not a parameter. A caller that could pass
|
||||
/// <c>skipChecks: true</c> is a caller that will, on the day the log is briefly unreachable, and the
|
||||
/// resulting grant is indistinguishable from a correct one afterwards.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What this still cannot promise is that the key belongs to the person you meant. Compare
|
||||
/// <see cref="VerifiedRecipient.Fingerprint"/> with them over a channel this server does not carry;
|
||||
/// that is the only step that closes the gap, and the outcome message says so.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<ShareOutcome> ShareVaultAsync(
|
||||
IVaultGrantApi grants,
|
||||
IDirectoryApi directory,
|
||||
Guid vaultId,
|
||||
Guid recipientUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(grants);
|
||||
ArgumentNullException.ThrowIfNull(directory);
|
||||
|
||||
if (!keyring.TryGet(vaultId, out var vaultKey, out var keyGeneration))
|
||||
{
|
||||
throw new VaultUnreadableException(vaultId);
|
||||
}
|
||||
|
||||
var entry = await directory.LookupByIdAsync(recipientUserId, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var log = await KeyLogAudit.ReadAsync(directory, cancellationToken).ConfigureAwait(false);
|
||||
var verification = KeyLogAudit.Verify(log, entry);
|
||||
|
||||
if (!verification.IsVerified)
|
||||
{
|
||||
return new ShareOutcome(false, verification, verification.Message);
|
||||
}
|
||||
|
||||
var recipient = verification.Recipient!;
|
||||
|
||||
await IssueAsync(grants, vaultId, vaultKey, keyGeneration, recipient, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new ShareOutcome(
|
||||
true,
|
||||
verification,
|
||||
"Shared. Check the fingerprint with them out of band — everything the client can verify on "
|
||||
+ "its own only proves this server has been consistent with itself.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-reads which vaults the server says are reachable, and opens any that have become readable.
|
||||
/// </summary>
|
||||
/// <returns>How many vaults this call made readable that were not before.</returns>
|
||||
/// <remarks>
|
||||
/// Called after a share and on a periodic pass. A vault somebody shared a minute ago arrives as a
|
||||
/// new entry with a wrapped key attached; one whose grant was revoked arrives without one, and is
|
||||
/// marked unreadable rather than quietly dropped so the interface can say what happened. Items
|
||||
/// already pulled are deliberately left alone — see <see cref="VaultStore.ReplaceAllAsync"/>.
|
||||
/// </remarks>
|
||||
public async Task<int> RefreshVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
ArgumentNullException.ThrowIfNull(api);
|
||||
|
||||
var me = await api.GetMeAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await Vault.ReplaceAllAsync([.. me.Vaults.Select(ToStored)], cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Vaults = await Vault.ListAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var admitted = 0;
|
||||
|
||||
foreach (var vault in Vaults)
|
||||
{
|
||||
if (keyring.CanRead(vault.VaultId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (keyring.TryAdmit(bundle, vault))
|
||||
{
|
||||
admitted++;
|
||||
}
|
||||
else
|
||||
{
|
||||
keyring.MarkUnreadable(vault.VaultId);
|
||||
}
|
||||
}
|
||||
|
||||
return admitted;
|
||||
}
|
||||
|
||||
/// <summary>Signs and posts one grant.</summary>
|
||||
private async Task IssueAsync(
|
||||
IVaultGrantApi grants,
|
||||
Guid vaultId,
|
||||
ReadOnlyMemory<byte> vaultKey,
|
||||
uint keyGeneration,
|
||||
VerifiedRecipient recipient,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
var entry = recipient.Entry;
|
||||
|
||||
var wrapped = VaultKeys.WrapTo(
|
||||
vaultKey.Span, entry.EncryptionPublicKey, vaultId, keyGeneration);
|
||||
|
||||
var ownFingerprint = DshCrypto.ComputeFingerprint(
|
||||
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
|
||||
|
||||
var canonical = GrantStatementCodec.Encode(
|
||||
vaultId,
|
||||
keyGeneration,
|
||||
GrantPurpose.Member,
|
||||
granteeUserId: entry.UserId,
|
||||
granteeKeyFingerprint: recipient.Fingerprint,
|
||||
wrappedKey: wrapped,
|
||||
granterUserId: Profile.UserId,
|
||||
granterKeyFingerprint: ownFingerprint,
|
||||
|
||||
// Present, unlike a self-grant's. This is the third-party case the head exists for: it
|
||||
// records which view of the key log this client held while wrapping, so a server showing
|
||||
// two clients different logs has to keep both stories straight for ever after.
|
||||
keyLogHead: recipient.KeyLogHead,
|
||||
grantedAt: now);
|
||||
|
||||
await grants.IssueVaultGrantAsync(
|
||||
vaultId,
|
||||
new IssueVaultGrantRequest(
|
||||
RecipientUserId: entry.UserId,
|
||||
RecipientKeyFingerprint: recipient.Fingerprint,
|
||||
KeyGeneration: keyGeneration,
|
||||
WrappedVaultKey: wrapped,
|
||||
KeyLogHead: recipient.KeyLogHead,
|
||||
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
|
||||
GrantedAt: now),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The signature covers the vault id, so the id has to be chosen before anything is wrapped — which
|
||||
/// is also what makes a create whose response was lost safe to send again.
|
||||
/// </remarks>
|
||||
private CreateTeamVaultRequest BuildCreateRequest(
|
||||
Guid vaultId,
|
||||
byte[] vaultKey,
|
||||
string name,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
var wrapped = VaultKeys.WrapTo(vaultKey, bundle.EncryptionPublicKey, vaultId, 1);
|
||||
|
||||
var fingerprint = DshCrypto.ComputeFingerprint(
|
||||
bundle.EncryptionPublicKey, bundle.SigningPublicKey);
|
||||
|
||||
var canonical = GrantStatementCodec.Encode(
|
||||
vaultId,
|
||||
keyGeneration: 1,
|
||||
GrantPurpose.Member,
|
||||
granteeUserId: Profile.UserId,
|
||||
granteeKeyFingerprint: fingerprint,
|
||||
wrappedKey: wrapped,
|
||||
granterUserId: Profile.UserId,
|
||||
granterKeyFingerprint: fingerprint,
|
||||
keyLogHead: default,
|
||||
grantedAt: now);
|
||||
|
||||
return new CreateTeamVaultRequest(
|
||||
VaultId: vaultId,
|
||||
Name: name,
|
||||
WrappedVaultKey: wrapped,
|
||||
GrantSignature: GrantStatementCodec.Sign(bundle.SigningKey, canonical),
|
||||
GrantedAt: now);
|
||||
}
|
||||
|
||||
private static StoredVault ToStored(VaultSummary summary) =>
|
||||
new(
|
||||
summary.VaultId,
|
||||
summary.Name,
|
||||
summary.IsPersonal,
|
||||
summary.TeamId,
|
||||
summary.KeyGeneration,
|
||||
summary.Permissions,
|
||||
summary.WrappedVaultKey,
|
||||
summary.RekeyRequired);
|
||||
}
|
||||
@@ -141,6 +141,7 @@
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -163,6 +164,12 @@
|
||||
"DodoSSH.Crypto": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.terminal": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
@@ -27,6 +27,19 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
|
||||
<!--
|
||||
Both arrived with the view models rather than being chosen here. ImportViewModel reads an
|
||||
~/.ssh/config, and TransfersViewModel puts a bucket behind IRemoteFileStore beside an SFTP host.
|
||||
|
||||
Worth knowing for the Android head, which gets both transitively and will use neither at first:
|
||||
scoped storage means there is no ~/.ssh/config to find, and file transfer is out of its first
|
||||
scope by decision. Neither is a problem — they are managed assemblies that simply go unused — but
|
||||
the day the phone grows a file screen, the bucket is the half that ports and the local pane is not.
|
||||
See docs/android-port.md.
|
||||
-->
|
||||
<ProjectReference Include="../DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Import;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||
/// <remarks>
|
||||
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
|
||||
/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
|
||||
/// rather than an action.
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly ImportedHost host;
|
||||
|
||||
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent)
|
||||
{
|
||||
this.host = host;
|
||||
AlreadyPresent = alreadyPresent;
|
||||
|
||||
// A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
|
||||
// for one machine is a thing people genuinely want — but it should take a click rather than be the
|
||||
// default.
|
||||
IsSelected = !alreadyPresent;
|
||||
}
|
||||
|
||||
internal ImportedHost Host => host;
|
||||
|
||||
internal string Alias => host.Alias;
|
||||
|
||||
internal string Address => host.Address;
|
||||
|
||||
/// <summary>Whether a host with this address is already in the keychain.</summary>
|
||||
internal bool AlreadyPresent { get; }
|
||||
|
||||
internal string Badge => AlreadyPresent ? "already here" : string.Empty;
|
||||
|
||||
internal bool HasBadge => AlreadyPresent;
|
||||
|
||||
/// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
|
||||
/// <remarks>
|
||||
/// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
|
||||
/// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
|
||||
/// connection that does not work.
|
||||
/// </remarks>
|
||||
internal string Authentication => host.IdentityFiles.Count switch
|
||||
{
|
||||
0 => "password",
|
||||
1 => $"a key on disk · {host.IdentityFiles[0]}",
|
||||
var count => $"{count} keys on disk · {host.IdentityFiles[0]}",
|
||||
};
|
||||
|
||||
internal bool HasWarnings => host.Warnings.Count > 0;
|
||||
|
||||
internal string Warnings => string.Join(" ", host.Warnings);
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isSelected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reading <c>~/.ssh/config</c> and offering what it found.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two steps, and the first one writes nothing.</b> Scanning reads the file and shows what it means;
|
||||
/// importing is a separate press. That split is the feature: an <c>ssh_config</c> is a file this
|
||||
/// application did not write and may contain forty entries for machines that no longer exist, so the
|
||||
/// interesting question is not "can it be parsed" but "which of these did you actually want".
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
|
||||
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
|
||||
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
|
||||
/// for making one deliberately, and pasting an existing one is a deliberate act too.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : ObservableObject
|
||||
{
|
||||
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
|
||||
|
||||
/// <summary>What was skipped or flattened, at document level.</summary>
|
||||
internal ObservableCollection<string> Warnings { get; } = [];
|
||||
|
||||
/// <summary>The file this would read, shown so nobody has to guess which one it means.</summary>
|
||||
internal string ConfigPath => locator.ConfigPath;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool hasScanned;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
internal bool HasRows => Rows.Count > 0;
|
||||
|
||||
internal bool HasWarnings => Warnings.Count > 0;
|
||||
|
||||
internal int SelectedCount => Rows.Count(row => row.IsSelected);
|
||||
|
||||
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
||||
|
||||
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Rows.Clear();
|
||||
Warnings.Clear();
|
||||
HasScanned = false;
|
||||
|
||||
if (!locator.Exists)
|
||||
{
|
||||
Status = $"There is no {locator.ConfigPath} on this machine.";
|
||||
RaiseListState();
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
foreach (var host in import.Hosts)
|
||||
{
|
||||
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host)));
|
||||
}
|
||||
|
||||
foreach (var warning in import.Warnings)
|
||||
{
|
||||
Warnings.Add(warning);
|
||||
}
|
||||
|
||||
HasScanned = true;
|
||||
|
||||
Status = Rows.Count == 0
|
||||
? "Nothing in that file could be imported as a host."
|
||||
: $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
|
||||
}
|
||||
catch (IOException failure)
|
||||
{
|
||||
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
||||
}
|
||||
catch (UnauthorizedAccessException failure)
|
||||
{
|
||||
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stores the ticked hosts.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ImportAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var chosen = Rows.Where(row => row.IsSelected).ToList();
|
||||
|
||||
if (chosen.Count == 0)
|
||||
{
|
||||
Status = "Nothing is ticked.";
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
var imported = await vault
|
||||
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
||||
// pressing the button twice harmless and visible rather than harmless and confusing.
|
||||
foreach (var row in Rows.ToList())
|
||||
{
|
||||
Rows[Rows.IndexOf(row)] = new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host));
|
||||
}
|
||||
|
||||
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ticks or unticks everything at once.</summary>
|
||||
[RelayCommand]
|
||||
private void ToggleAll()
|
||||
{
|
||||
var target = SelectedCount < Rows.Count;
|
||||
|
||||
foreach (var row in Rows)
|
||||
{
|
||||
row.IsSelected = target;
|
||||
}
|
||||
|
||||
RaiseListState();
|
||||
}
|
||||
|
||||
internal void NoteSelectionChanged() => RaiseListState();
|
||||
|
||||
/// <remarks>
|
||||
/// Matched on where a host points rather than on what it is called. Two entries with different aliases
|
||||
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
|
||||
/// to import a duplicate of something already stored under another name.
|
||||
/// </remarks>
|
||||
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
|
||||
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
|
||||
&& existing.Host.Port == host.Port
|
||||
&& string.Equals(existing.Host.Username, host.Username, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void RaiseListState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasRows));
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(ImportLabel));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One pinned host key, as a row in the list.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
|
||||
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
|
||||
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
|
||||
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
|
||||
/// What was wrong was that nothing ever showed them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
|
||||
/// of pinning one is to compare it with what they published.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class KnownHostRowViewModel(
|
||||
VaultItem<KnownHostSecret> pin,
|
||||
bool isDialledByAHost,
|
||||
Guid vaultId,
|
||||
string vaultName)
|
||||
{
|
||||
/// <summary>Which vault this pin lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
internal Guid EntityId => pin.EntityId;
|
||||
|
||||
internal KnownHostSecret Pin => pin.Secret;
|
||||
|
||||
internal string Host => pin.Secret.Host;
|
||||
|
||||
internal int Port => pin.Secret.Port;
|
||||
|
||||
internal string Algorithm => pin.Secret.Algorithm;
|
||||
|
||||
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
|
||||
internal string Label => pin.Secret.Label;
|
||||
|
||||
/// <summary>The fingerprint, in full.</summary>
|
||||
/// <remarks>
|
||||
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
|
||||
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
|
||||
/// this whole mechanism exists to replace.
|
||||
/// </remarks>
|
||||
internal string Fingerprint => pin.Secret.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any host in this vault actually dials the endpoint this pin is for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
|
||||
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
|
||||
/// worth deleting on the user's behalf.
|
||||
/// </remarks>
|
||||
internal bool IsDialledByAHost { get; } = isDialledByAHost;
|
||||
|
||||
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
|
||||
|
||||
internal string Badge => IsDialledByAHost
|
||||
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
|
||||
: "no host uses this";
|
||||
|
||||
/// <summary>
|
||||
/// When this pin was approved, as far as anything here can tell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
|
||||
/// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
|
||||
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
|
||||
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
|
||||
/// renders as a dash rather than as a guess.
|
||||
/// </remarks>
|
||||
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
|
||||
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
|
||||
: "—";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host keys this keychain has approved, and how to withdraw one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
|
||||
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
|
||||
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
|
||||
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
|
||||
/// it produces, neither of which the vault has any use for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
|
||||
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
|
||||
/// answer a question nobody is asking.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
|
||||
internal KnownHostsViewModel(VaultViewModel vault)
|
||||
{
|
||||
this.vault = vault;
|
||||
|
||||
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
|
||||
// copy of a trust decision is the one kind of staleness that matters here.
|
||||
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>The pins this filter admits, in the order the vault produced them.</summary>
|
||||
/// <remarks>
|
||||
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
|
||||
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
|
||||
/// — host, then port, then algorithm — is the one worth keeping.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string filter = string.Empty;
|
||||
|
||||
/// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
|
||||
/// <remarks>
|
||||
/// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
|
||||
/// and there is no reason for it to learn about this screen.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selected;
|
||||
|
||||
internal bool HasPins => vault.KnownHostPins.Count > 0;
|
||||
|
||||
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
|
||||
/// <summary>What the whole list amounts to, in one line.</summary>
|
||||
/// <remarks>
|
||||
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
|
||||
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
|
||||
/// might want to act on, and counting them is cheaper than reading a badge column.
|
||||
/// </remarks>
|
||||
internal string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = vault.KnownHostPins.Count;
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
|
||||
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
|
||||
|
||||
return unused == 0
|
||||
? pins
|
||||
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
|
||||
}
|
||||
}
|
||||
|
||||
internal string EmptyMessage => HasPins
|
||||
? "No approved host key matches that."
|
||||
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
|
||||
+ "check — approving it puts it here.";
|
||||
|
||||
/// <summary>Withdraws trust in the selected pin.</summary>
|
||||
/// <remarks>
|
||||
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
|
||||
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
|
||||
/// server are the other ones.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ForgetSelectedAsync()
|
||||
{
|
||||
if (Selected is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
partial void OnSelectedChanged(KnownHostRowViewModel? value)
|
||||
{
|
||||
vault.SelectedKnownHost = value;
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
}
|
||||
|
||||
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
|
||||
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
|
||||
// writes that null straight back before the refill can matter.
|
||||
var selectedId = Selected?.EntityId;
|
||||
|
||||
VisiblePins.Clear();
|
||||
|
||||
foreach (var pin in vault.KnownHostPins.Where(Matches))
|
||||
{
|
||||
VisiblePins.Add(pin);
|
||||
}
|
||||
|
||||
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasPins));
|
||||
OnPropertyChanged(nameof(HasVisiblePins));
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
|
||||
private bool Matches(KnownHostRowViewModel pin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Filter))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var needle = Filter.Trim();
|
||||
|
||||
return Contains(pin.Host, needle)
|
||||
|| Contains(pin.Algorithm, needle)
|
||||
|| Contains(pin.Fingerprint, needle)
|
||||
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
|
||||
}
|
||||
|
||||
private static bool Contains(string haystack, string needle) =>
|
||||
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>Which log the screen is showing.</summary>
|
||||
internal enum LogSection
|
||||
{
|
||||
/// <summary>Connections that were made.</summary>
|
||||
Connections,
|
||||
|
||||
/// <summary>Changes made to keychain items.</summary>
|
||||
Activity,
|
||||
}
|
||||
|
||||
/// <summary>One connection, as a row.</summary>
|
||||
internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> entry, bool isLive)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string HostLabel => entry.Secret.HostLabel;
|
||||
|
||||
internal string Address => entry.Secret.Address;
|
||||
|
||||
/// <summary>When it started, in the reader's own conventions.</summary>
|
||||
/// <remarks>
|
||||
/// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
|
||||
/// difference is the reason each is right. There, two panes are read against one another and a
|
||||
/// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
|
||||
/// machine", which is a question about the reader's own day. <c>InvariantGlobalization</c> is false in
|
||||
/// the client csproj precisely so this works.
|
||||
/// </remarks>
|
||||
internal string Started =>
|
||||
entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// How long it lasted, or that it has not finished.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>"still open" and not a dash.</b> A dash reads as "nothing was recorded", and the two are opposite
|
||||
/// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
|
||||
/// session has no entry at all until it closes, so this state comes from the workspace rather than from
|
||||
/// the vault; see <see cref="LogsViewModel"/>.
|
||||
/// </remarks>
|
||||
internal string Duration => isLive
|
||||
? "still open"
|
||||
: Humanise(entry.Secret.Duration);
|
||||
|
||||
internal bool IsLive => isLive;
|
||||
|
||||
internal string Outcome => entry.Secret.Outcome switch
|
||||
{
|
||||
ConnectionOutcome.Failed => "failed",
|
||||
ConnectionOutcome.Refused => "host key refused",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
internal bool HasOutcome => Outcome.Length > 0;
|
||||
|
||||
/// <summary>Whether this was a terminal or the file browser.</summary>
|
||||
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
|
||||
/// <remarks>
|
||||
/// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
|
||||
/// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
|
||||
/// rounding themselves.
|
||||
/// </remarks>
|
||||
private static string Humanise(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
|
||||
}
|
||||
|
||||
if (duration < TimeSpan.FromHours(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
|
||||
}
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One keychain change, as a row.</summary>
|
||||
internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string ItemLabel => entry.Secret.ItemLabel;
|
||||
|
||||
internal string ItemKind => entry.Secret.ItemKind;
|
||||
|
||||
internal string Operation => entry.Secret.Operation switch
|
||||
{
|
||||
ActivityOperation.Created => "created",
|
||||
ActivityOperation.Deleted => "deleted",
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
||||
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Which fields changed. Never what they changed to.</summary>
|
||||
internal string ChangedFields => entry.Secret.ChangedFields;
|
||||
|
||||
internal bool HasChangedFields => ChangedFields.Length > 0;
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What has been connected to, and what has been changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
|
||||
/// section switch and one thing neither log knows: which connections are happening <em>now</em>. An entry is
|
||||
/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
|
||||
/// workspace, and this screen is where the two are put side by side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Read on demand rather than kept in step.</b> Unlike the host list, a log is not something a background
|
||||
/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
|
||||
/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
|
||||
/// nobody is looking at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class LogsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultSession session;
|
||||
private readonly Func<IReadOnlyList<LiveConnection>> live;
|
||||
|
||||
/// <param name="session">The open vault, which holds both logs.</param>
|
||||
/// <param name="live">
|
||||
/// The connections that are open right now. A function rather than a list, because tabs open and close
|
||||
/// while this screen is showing and it is not told about either.
|
||||
/// </param>
|
||||
internal LogsViewModel(VaultSession session, Func<IReadOnlyList<LiveConnection>> live)
|
||||
{
|
||||
this.session = session;
|
||||
this.live = live;
|
||||
}
|
||||
|
||||
/// <summary>Connections, newest first, with anything still open at the top.</summary>
|
||||
internal ObservableCollection<ConnectionLogRowViewModel> Connections { get; } = [];
|
||||
|
||||
/// <summary>Keychain changes, newest first.</summary>
|
||||
internal ObservableCollection<ActivityLogRowViewModel> Activity { get; } = [];
|
||||
|
||||
/// <remarks>
|
||||
/// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
|
||||
/// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
|
||||
/// a command can refuse it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private LogSection section;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool ShowsConnections => Section is LogSection.Connections;
|
||||
|
||||
internal bool ShowsActivity => Section is LogSection.Activity;
|
||||
|
||||
internal bool HasConnections => Connections.Count > 0;
|
||||
|
||||
internal bool HasActivity => Activity.Count > 0;
|
||||
|
||||
internal string EmptyMessage => Section is LogSection.Connections
|
||||
? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
|
||||
+ "top and gets its line when you close the tab."
|
||||
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
||||
+ "names of the fields that changed, never their contents.";
|
||||
|
||||
/// <summary>Shows one of the two logs.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowSection(LogSection section) => Section = section;
|
||||
|
||||
/// <summary>Re-reads both logs.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = string.Empty;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Leaving the screen.
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads both logs into the lists.</summary>
|
||||
internal async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connections = await session.ConnectionLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var activity = await session.ActivityLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Connections.Clear();
|
||||
|
||||
// The live ones first and above everything, because they are the only rows in this list that are
|
||||
// still changing. They carry no entity id — there is no vault item for them yet — which is why they
|
||||
// are built from a different source and marked as live rather than merged into the same shape.
|
||||
foreach (var open in live())
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(
|
||||
new VaultItem<ConnectionLogSecret>(
|
||||
Guid.Empty,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = open.HostLabel,
|
||||
Address = open.Address,
|
||||
StartedAt = open.StartedAt,
|
||||
DeviceName = open.DeviceName,
|
||||
},
|
||||
Version: 0,
|
||||
HasUnsyncedChanges: false,
|
||||
IsBlocked: false,
|
||||
IsReadOnly: false),
|
||||
isLive: true));
|
||||
}
|
||||
|
||||
foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
|
||||
}
|
||||
|
||||
Activity.Clear();
|
||||
|
||||
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
|
||||
{
|
||||
Activity.Add(new ActivityLogRowViewModel(entry));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasConnections));
|
||||
OnPropertyChanged(nameof(HasActivity));
|
||||
}
|
||||
|
||||
partial void OnSectionChanged(LogSection value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsConnections));
|
||||
OnPropertyChanged(nameof(ShowsActivity));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A connection that is open right now.</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>
|
||||
/// <param name="DeviceName">This machine.</param>
|
||||
/// <remarks>
|
||||
/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
|
||||
/// that is still running has no entry there, because an entry is written once and at close — which is what
|
||||
/// keeps a synced log from needing a merge.
|
||||
/// </remarks>
|
||||
internal sealed record LiveConnection(
|
||||
string HostLabel,
|
||||
string Address,
|
||||
DateTimeOffset StartedAt,
|
||||
string DeviceName);
|
||||
@@ -6,6 +6,8 @@ using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.ObjectStore;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
@@ -61,7 +63,7 @@ internal enum ShellState
|
||||
/// </remarks>
|
||||
internal enum ShellScreen
|
||||
{
|
||||
/// <summary>The host list and the terminals, which is where the application opens.</summary>
|
||||
/// <summary>The host list, which is where the application opens.</summary>
|
||||
Hosts = 0,
|
||||
|
||||
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
|
||||
@@ -75,6 +77,52 @@ internal enum ShellScreen
|
||||
|
||||
/// <summary>Preferences.</summary>
|
||||
Preferences = 4,
|
||||
|
||||
/// <summary>The host keys this keychain has approved.</summary>
|
||||
/// <remarks>
|
||||
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
|
||||
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
|
||||
/// silent change to what every one of those means.
|
||||
/// </remarks>
|
||||
KnownHosts = 5,
|
||||
|
||||
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
|
||||
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
|
||||
/// something almost nobody is looking at.
|
||||
/// </remarks>
|
||||
Import = 6,
|
||||
|
||||
/// <summary>The saved commands in this keychain.</summary>
|
||||
/// <inheritdoc cref="KnownHosts" path="/remarks" />
|
||||
Snippets = 7,
|
||||
|
||||
/// <summary>What has been connected to, and what has been changed.</summary>
|
||||
/// <inheritdoc cref="KnownHosts" path="/remarks" />
|
||||
Logs = 8,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
|
||||
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
|
||||
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
|
||||
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
|
||||
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
|
||||
/// with one half hidden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ShellSurface
|
||||
{
|
||||
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
|
||||
Page = 0,
|
||||
|
||||
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
|
||||
Terminal = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,6 +174,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
/// <remarks>
|
||||
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
|
||||
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
|
||||
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
|
||||
/// </remarks>
|
||||
private readonly Func<string, Task>? copyToClipboard;
|
||||
|
||||
/// <remarks>
|
||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
||||
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
||||
@@ -134,6 +189,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly TransfersViewModel transfers;
|
||||
|
||||
/// <summary>
|
||||
/// Where connections are recorded, for as long as a vault is open to record them into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
|
||||
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
|
||||
/// </remarks>
|
||||
private readonly ConnectionRecorder connectionLog;
|
||||
|
||||
private readonly TeamsViewModel teams;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -188,7 +254,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
TimeProvider clock,
|
||||
ISftpSessionFactory sftpSessions,
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null)
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
@@ -199,9 +266,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.resume = resume;
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
this.copyToClipboard = copyToClipboard;
|
||||
|
||||
transfers = new TransfersViewModel(sftpSessions, clock);
|
||||
|
||||
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
|
||||
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
|
||||
// running after it, and the entry it eventually produces belongs to the vault it was made in.
|
||||
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
|
||||
this.workspace.ConnectionLog = connectionLog;
|
||||
|
||||
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
|
||||
// session after unlock, and both go away again on lock. Capturing either would give this screen a
|
||||
// reference that outlives what it points at — which for a session means holding vault keys past the
|
||||
// moment locking is supposed to have zeroed them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
@@ -273,6 +353,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
|
||||
/// <remarks>
|
||||
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
|
||||
/// vault — unlocking, locking and signing out — cannot get out of step with it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private KnownHostsViewModel? knownHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private ImportViewModel? importScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private SnippetsViewModel? snippetsScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private LogsViewModel? logsScreen;
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen, which the window binds to whether or not a vault is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
|
||||
/// server rather than a vault, and both of its dependencies are fetched through a function at the
|
||||
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
|
||||
/// to rebuild it, and the list it is showing survives both.
|
||||
/// </remarks>
|
||||
internal TeamsViewModel Teams => teams;
|
||||
|
||||
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
|
||||
@@ -370,9 +481,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// ---- Which screen is showing ----
|
||||
|
||||
/// <summary>
|
||||
/// Which of the nav rail's screens the page area holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
|
||||
/// It is what dismissing a terminal returns to.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private ShellScreen screen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the page area is showing rather than a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
|
||||
/// <c>IsHostsScreen && IsShowingPages</c> in a binding, so the alternative is five compound
|
||||
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
|
||||
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
|
||||
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
|
||||
/// </remarks>
|
||||
internal bool IsShowingPages => Surface is ShellSurface.Page;
|
||||
|
||||
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
@@ -387,6 +517,51 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsImportScreen => Screen is ShellScreen.Import;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the nav rail should light its Hosts entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
|
||||
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
|
||||
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
|
||||
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
|
||||
/// at once is one too many.
|
||||
/// </remarks>
|
||||
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's WebView may be on screen at this instant.
|
||||
/// </summary>
|
||||
@@ -396,16 +571,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
|
||||
/// screens all use the full width), and the quick-connect palette.
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
|
||||
/// quick-connect palette.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a
|
||||
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
|
||||
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to
|
||||
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control
|
||||
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The
|
||||
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
|
||||
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
|
||||
/// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
|
||||
/// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
|
||||
/// to the same value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
|
||||
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
|
||||
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
|
||||
/// Avalonia draws and nothing occludes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
||||
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
|
||||
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
|
||||
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
|
||||
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
|
||||
/// answered here, and it cannot be: this property has no way to know when layout ran.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
|
||||
@@ -414,11 +601,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
|
||||
internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
|
||||
|
||||
/// <inheritdoc cref="ShellSurface" />
|
||||
[ObservableProperty]
|
||||
private ShellSurface surface;
|
||||
|
||||
/// <summary>Points the nav rail at a screen.</summary>
|
||||
/// <remarks>
|
||||
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
|
||||
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
|
||||
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ShowScreen(ShellScreen target) => Screen = target;
|
||||
private void ShowScreen(ShellScreen target)
|
||||
{
|
||||
Screen = target;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
@@ -470,6 +670,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||
}
|
||||
|
||||
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
|
||||
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
|
||||
// leave a visible WebView with no pane in it, which reads as the application having broken.
|
||||
if (Tabs.Count == 0)
|
||||
{
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
RaiseTabState();
|
||||
|
||||
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
||||
@@ -566,7 +774,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
|
||||
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
|
||||
// screen — so connecting from the files screen without this would put the question behind the screen
|
||||
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
|
||||
// is what moves the surface to the terminal, and only if there is one.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
|
||||
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
|
||||
@@ -746,7 +960,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Creating your vault. This deliberately takes a moment…",
|
||||
"Creating your keychain. This deliberately takes a moment…",
|
||||
async () =>
|
||||
{
|
||||
var chosen = Passphrase;
|
||||
@@ -796,7 +1010,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
if (Passphrase.Length == 0)
|
||||
{
|
||||
StatusMessage = "Enter your vault passphrase.";
|
||||
StatusMessage = "Enter your keychain passphrase.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -950,23 +1164,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
// Before the vault view model, so the first connection after an unlock already knows which host keys
|
||||
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
|
||||
// keeps it off the SSH handshake thread.
|
||||
try
|
||||
{
|
||||
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
|
||||
// session is vault keys left in memory for the life of the process, which is precisely what
|
||||
// unlocking must be able to undo.
|
||||
await session.DisposeAsync().ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
|
||||
Vault = new VaultViewModel(
|
||||
session,
|
||||
workspace,
|
||||
knownHosts,
|
||||
() => connection,
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -984,7 +1191,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(Vault, knownHosts);
|
||||
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||
|
||||
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
|
||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
||||
@@ -1007,6 +1214,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Vault.StartAutoSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the two process-lifetime stores at the session that has just opened.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
|
||||
/// called by the workspace — so both are attached here rather than constructed per session, and both are
|
||||
/// released together on every path that closes a vault.
|
||||
/// </remarks>
|
||||
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
// Before the vault view model, so the first connection after an unlock already knows which host keys
|
||||
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
|
||||
// keeps it off the SSH handshake thread.
|
||||
try
|
||||
{
|
||||
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
|
||||
// session is vault keys left in memory for the life of the process, which is precisely what
|
||||
// unlocking must be able to undo.
|
||||
await session.DisposeAsync().ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
|
||||
// The actor is the account that unlocked, which is what makes this an audit record rather than a
|
||||
// list of events with nobody attached to them.
|
||||
connectionLog.Open(session, session.Profile.UserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
|
||||
/// </summary>
|
||||
@@ -1214,6 +1452,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// reappearing behind a lock screen.
|
||||
knownHosts.Close();
|
||||
|
||||
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
|
||||
// about to be disposed. Tickets already open keep the repository they were opened against, so a
|
||||
// shell still running closes out into the vault it was actually made in.
|
||||
connectionLog.Close();
|
||||
|
||||
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
|
||||
// holding references to them. What it does not give up is its connection or its queue — a transfer
|
||||
// in flight is exactly the work this method exists not to destroy.
|
||||
@@ -1264,7 +1507,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
(false, _) =>
|
||||
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
|
||||
+ "counted from here, because the vault is locked.",
|
||||
+ "counted from here, because the keychain is locked.",
|
||||
(true, 0) =>
|
||||
"Everything this machine has changed has reached the server, so nothing will be lost.",
|
||||
(true, 1) =>
|
||||
@@ -1328,6 +1571,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// As Lock does, and before the session it reads from goes.
|
||||
knownHosts.Close();
|
||||
connectionLog.Close();
|
||||
|
||||
// The same detach locking does, and the same reasoning carried one step further: the host
|
||||
// rows go because the vault behind them is about to be disposed, and the session and its
|
||||
@@ -1364,8 +1608,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
|
||||
+ "itself is untouched. Sign in to set this machine up again.";
|
||||
StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
|
||||
+ "keychain itself is untouched. Sign in to set this machine up again.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -1412,6 +1656,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
knownHosts.Close();
|
||||
|
||||
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
|
||||
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
|
||||
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
|
||||
workspace.ConnectionLog = null;
|
||||
await connectionLog.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
|
||||
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
|
||||
// that reached it.
|
||||
@@ -1547,6 +1797,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
|
||||
}
|
||||
|
||||
// Built from the vault and thrown away with it, here rather than at each of the three places a
|
||||
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
|
||||
// would keep a disposed vault alive and repaint a screen nobody can reach.
|
||||
KnownHostsScreen?.Detach();
|
||||
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
|
||||
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
|
||||
|
||||
SnippetsScreen?.Detach();
|
||||
SnippetsScreen = newValue is null
|
||||
? null
|
||||
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
|
||||
|
||||
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
|
||||
|
||||
RaiseSyncState();
|
||||
}
|
||||
|
||||
@@ -1579,13 +1843,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
|
||||
/// runs against a tab strip that already shows the session it is focusing.
|
||||
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
|
||||
/// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
|
||||
/// </remarks>
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
|
||||
{
|
||||
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
|
||||
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
||||
|
||||
/// <summary>
|
||||
/// Takes a newly opened session into the tab strip and shows it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One method rather than one per way of opening a session, so the order of these four steps is decided
|
||||
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
|
||||
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
|
||||
/// </remarks>
|
||||
private void AdoptTab(TerminalTabViewModel tab)
|
||||
{
|
||||
Tabs.Add(tab);
|
||||
RaiseTabState();
|
||||
|
||||
@@ -1594,6 +1867,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// and load-bearing for every one after it.
|
||||
SelectedTab = tab;
|
||||
|
||||
// The surface, but deliberately not the screen. A session opened from the files screen shows its
|
||||
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
|
||||
// clicking away comes back to the transfer that is presumably still running.
|
||||
Surface = ShellSurface.Terminal;
|
||||
|
||||
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -1617,15 +1895,50 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
RefreshConnectedHosts();
|
||||
|
||||
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
|
||||
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
|
||||
// be a screen keeping the shell alive.
|
||||
SnippetsScreen?.TargetChanged();
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Brings one terminal's pane to the front.</summary>
|
||||
/// <summary>Which terminal a snippet would go into right now.</summary>
|
||||
/// <remarks>
|
||||
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
|
||||
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
|
||||
/// say, the most recently opened would send a command somewhere the user is not looking.
|
||||
/// </remarks>
|
||||
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
|
||||
/// <remarks>
|
||||
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
|
||||
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
|
||||
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<LiveConnection> LiveConnections() =>
|
||||
[
|
||||
.. connectionLog.Open().Select(open => new LiveConnection(
|
||||
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
|
||||
];
|
||||
|
||||
private InsertTarget CurrentInsertTarget() =>
|
||||
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
|
||||
|
||||
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
|
||||
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
|
||||
/// would answer only one of those.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
|
||||
private void SelectTab(TerminalTabViewModel tab)
|
||||
{
|
||||
SelectedTab = tab;
|
||||
Surface = ShellSurface.Terminal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
@@ -1689,10 +2002,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
RaiseSyncState();
|
||||
|
||||
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
|
||||
// The hosts screen is what this application is for.
|
||||
// The hosts screen is what this application is for. The surface as well as the screen: shells outlive
|
||||
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
|
||||
// the application would not be what "unlocked" looks like.
|
||||
if (value is ShellState.Unlocked)
|
||||
{
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1702,12 +2018,57 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// directions, and raising only the one that became true leaves the old button lit.
|
||||
/// </remarks>
|
||||
partial void OnScreenChanged(ShellScreen value)
|
||||
{
|
||||
RaiseSurfaceState();
|
||||
|
||||
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
||||
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
||||
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
||||
if (value is ShellScreen.Logs && LogsScreen is { } logs)
|
||||
{
|
||||
_ = logs.RefreshCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// Teams are read from the server rather than from the vault, so there is nothing to show until
|
||||
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
|
||||
// screen most people never open. Fire-and-forget because a property change cannot await, and
|
||||
// because the view model turns every failure into its own status line rather than throwing.
|
||||
if (value is ShellScreen.Team)
|
||||
{
|
||||
_ = teams.LoadAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
|
||||
|
||||
/// <remarks>
|
||||
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
|
||||
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
|
||||
/// narrow what became stale.
|
||||
/// </remarks>
|
||||
private void RaiseSurfaceState()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
OnPropertyChanged(nameof(IsVaultScreen));
|
||||
OnPropertyChanged(nameof(IsTeamScreen));
|
||||
OnPropertyChanged(nameof(IsPreferencesScreen));
|
||||
OnPropertyChanged(nameof(IsKnownHostsScreen));
|
||||
OnPropertyChanged(nameof(IsImportScreen));
|
||||
OnPropertyChanged(nameof(IsSnippetsScreen));
|
||||
OnPropertyChanged(nameof(IsLogsScreen));
|
||||
|
||||
OnPropertyChanged(nameof(IsShowingPages));
|
||||
OnPropertyChanged(nameof(IsHostsShowing));
|
||||
OnPropertyChanged(nameof(IsTransfersShowing));
|
||||
OnPropertyChanged(nameof(IsVaultShowing));
|
||||
OnPropertyChanged(nameof(IsTeamShowing));
|
||||
OnPropertyChanged(nameof(IsPreferencesShowing));
|
||||
OnPropertyChanged(nameof(IsKnownHostsShowing));
|
||||
OnPropertyChanged(nameof(IsSnippetsShowing));
|
||||
OnPropertyChanged(nameof(IsLogsShowing));
|
||||
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>Where a snippet is about to be inserted, and whether it can be.</summary>
|
||||
/// <param name="SessionId">The terminal, or null when there is none open.</param>
|
||||
/// <param name="Label">What that terminal is called, for the button.</param>
|
||||
internal sealed record InsertTarget(uint? SessionId, string Label)
|
||||
{
|
||||
/// <summary>The answer when no tab is open.</summary>
|
||||
internal static InsertTarget None { get; } = new(null, string.Empty);
|
||||
|
||||
/// <summary>Whether there is somewhere to insert into.</summary>
|
||||
internal bool IsAvailable => SessionId is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The saved commands in this keychain, and how to get one into a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A wrapper over the vault, as <c>KnownHostsViewModel</c> is</b>, and for the same reason: reading
|
||||
/// snippets, storing one and pushing the change already live on <see cref="VaultViewModel"/>, wired into its
|
||||
/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
|
||||
/// the vault has any use for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The safety story is the copy, not the code.</b> A terminal is one input stream with no notion of being
|
||||
/// at a prompt: the remote may be in <c>vi</c>, or at a <c>sudo</c> password prompt with echo off, and
|
||||
/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
|
||||
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
|
||||
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
private readonly Func<InsertTarget> target;
|
||||
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
|
||||
|
||||
/// <param name="vault">The open keychain, which owns the list and the writing.</param>
|
||||
/// <param name="target">
|
||||
/// Which terminal is selected right now. A function rather than a value, because the answer changes every
|
||||
/// time the user clicks a tab and this screen is not told about that.
|
||||
/// </param>
|
||||
/// <param name="insert">
|
||||
/// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
|
||||
/// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
|
||||
/// neither of those is a property of the transport.
|
||||
/// </param>
|
||||
internal SnippetsViewModel(
|
||||
VaultViewModel vault,
|
||||
Func<InsertTarget> target,
|
||||
Func<uint, string, bool, CancellationToken, Task<bool>> insert)
|
||||
{
|
||||
this.vault = vault;
|
||||
this.target = target;
|
||||
this.insert = insert;
|
||||
|
||||
vault.Snippets.CollectionChanged += OnSnippetsChanged;
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>The snippets this filter admits, in the order the vault produced them.</summary>
|
||||
internal ObservableCollection<SnippetRowViewModel> Visible { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string filter = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SnippetRowViewModel? selected;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEditing;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorCommand = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorNotes = string.Empty;
|
||||
|
||||
/// <summary>Whether the snippet being edited is one that presses Enter for you.</summary>
|
||||
/// <remarks>
|
||||
/// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
|
||||
/// per snippet rather than a preference, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c> do
|
||||
/// not want the same answer and one switch would end up left on by whoever needed it for the first.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool editorRunsOnInsert;
|
||||
|
||||
/// <summary>The snippet being edited, or null when the editor would create one.</summary>
|
||||
[ObservableProperty]
|
||||
private Guid? editingId;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool HasSnippets => vault.Snippets.Count > 0;
|
||||
|
||||
internal bool HasVisible => Visible.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
|
||||
/// <summary>Whether there is a terminal to insert into at all.</summary>
|
||||
internal bool CanInsert => HasSelection && target().IsAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// What the insert button says, naming the terminal it will type into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
|
||||
/// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
|
||||
/// command, at the moment that is least convenient to be wrong about.
|
||||
/// </remarks>
|
||||
internal string InsertLabel => target() is { IsAvailable: true } open
|
||||
? $"TYPE INTO {open.Label}"
|
||||
: "NO TERMINAL OPEN";
|
||||
|
||||
/// <summary>What the run button says, or empty when the selected snippet does not run.</summary>
|
||||
internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;
|
||||
|
||||
/// <summary>Whether the selected snippet is one marked as running on its own.</summary>
|
||||
internal bool SelectionRuns => Selected?.RunsOnInsert is true;
|
||||
|
||||
internal string EmptyMessage => HasSnippets
|
||||
? "No snippet matches that."
|
||||
: "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
|
||||
+ "typing it again.";
|
||||
|
||||
/// <summary>Starts a new snippet.</summary>
|
||||
[RelayCommand]
|
||||
private void New()
|
||||
{
|
||||
EditingId = null;
|
||||
EditorLabel = string.Empty;
|
||||
EditorCommand = string.Empty;
|
||||
EditorNotes = string.Empty;
|
||||
EditorRunsOnInsert = false;
|
||||
IsEditing = true;
|
||||
Status = "Adding a snippet.";
|
||||
}
|
||||
|
||||
/// <summary>Opens the selected snippet for editing.</summary>
|
||||
[RelayCommand]
|
||||
private void Edit()
|
||||
{
|
||||
if (Selected is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
|
||||
return;
|
||||
}
|
||||
|
||||
EditingId = row.EntityId;
|
||||
EditorLabel = row.Snippet.Label;
|
||||
EditorCommand = row.Snippet.Command;
|
||||
EditorNotes = row.Snippet.Notes ?? string.Empty;
|
||||
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
|
||||
IsEditing = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>Abandons the editor.</summary>
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
IsEditing = false;
|
||||
EditingId = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Stores the editor's contents.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var snippet = new SnippetSecret
|
||||
{
|
||||
Label = EditorLabel.Trim(),
|
||||
|
||||
// Not trimmed, and this is the field where that matters most. A here-document's terminator has
|
||||
// to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
|
||||
// for one that never comes, which reads as the snippet having hung the terminal.
|
||||
Command = EditorCommand,
|
||||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||||
RunsOnInsert = EditorRunsOnInsert,
|
||||
};
|
||||
|
||||
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (!saved)
|
||||
{
|
||||
Status = vault.Status;
|
||||
return;
|
||||
}
|
||||
|
||||
IsEditing = false;
|
||||
EditingId = null;
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>Deletes the selected snippet.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Selected is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The button that does not run anything, and it is the one a user should reach for. What it inserts
|
||||
/// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
|
||||
/// at the prompt as text and waits for a person to look at it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal and presses Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only offered for a snippet whose own <see cref="SnippetSecret.RunsOnInsert"/> is set, so that "this
|
||||
/// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
|
||||
/// every one of them.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task RunAsync(CancellationToken cancellationToken) =>
|
||||
SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;
|
||||
|
||||
/// <summary>Re-reads which terminal is selected, after the shell says one has changed.</summary>
|
||||
/// <remarks>
|
||||
/// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
|
||||
/// this screen — a session survives locking the keychain — so a subscription in this direction would be
|
||||
/// a screen holding the shell alive.
|
||||
/// </remarks>
|
||||
internal void TargetChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(InsertLabel));
|
||||
OnPropertyChanged(nameof(RunLabel));
|
||||
}
|
||||
|
||||
private async Task SendAsync(bool execute, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
|
||||
{
|
||||
Status = "Open a terminal first — a snippet has to go somewhere.";
|
||||
return;
|
||||
}
|
||||
|
||||
var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = delivered
|
||||
? execute
|
||||
? $"Ran '{row.Label}' in {open.Label}."
|
||||
: $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
|
||||
: $"{open.Label} is no longer connected, so nothing was sent.";
|
||||
}
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
partial void OnSelectedChanged(SnippetRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(SelectionRuns));
|
||||
}
|
||||
|
||||
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
|
||||
|
||||
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
|
||||
internal bool IsCreating => EditingId is null;
|
||||
|
||||
private void OnSnippetsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
|
||||
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
|
||||
// writes that null straight back before the refill can matter.
|
||||
var selectedId = Selected?.EntityId;
|
||||
|
||||
Visible.Clear();
|
||||
|
||||
foreach (var snippet in vault.Snippets.Where(Matches))
|
||||
{
|
||||
Visible.Add(snippet);
|
||||
}
|
||||
|
||||
Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasSnippets));
|
||||
OnPropertyChanged(nameof(HasVisible));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The command is searched as well as the name and the notes, because half of what somebody remembers
|
||||
/// about a saved command is a word that was in it.
|
||||
/// </remarks>
|
||||
private bool Matches(SnippetRowViewModel row)
|
||||
{
|
||||
var needle = Filter.Trim();
|
||||
|
||||
if (needle.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);
|
||||
|
||||
bool Contains(string? value) =>
|
||||
value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One team, as a row in the list.</summary>
|
||||
internal sealed record TeamRowViewModel(TeamSummary Team)
|
||||
{
|
||||
internal Guid TeamId => Team.TeamId;
|
||||
|
||||
internal string Name => Team.Name;
|
||||
|
||||
internal string Slug => Team.Slug;
|
||||
|
||||
/// <summary>The caller's own role, as the chip the list shows.</summary>
|
||||
internal string Role => Team.Role.ToString().ToUpperInvariant();
|
||||
|
||||
internal string Detail => string.Create(
|
||||
CultureInfo.CurrentCulture,
|
||||
$"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");
|
||||
|
||||
/// <summary>Whether this account may add members and create vaults here.</summary>
|
||||
internal bool CanAdminister =>
|
||||
Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One member, as a row in the members table.</summary>
|
||||
internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
|
||||
{
|
||||
internal Guid UserId => Member.UserId;
|
||||
|
||||
/// <summary>What to call them. The address, or the id when the account has neither.</summary>
|
||||
/// <remarks>
|
||||
/// Falling through to the id rather than to "Unknown": an account with no display name and no email is
|
||||
/// rare and is exactly the row somebody needs to be able to identify in order to remove it.
|
||||
/// </remarks>
|
||||
internal string Name =>
|
||||
Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();
|
||||
|
||||
internal string Email => Member.Email ?? "—";
|
||||
|
||||
internal string Role => Member.Role.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// What the account can be given, in one phrase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a two-factor column, not a last-active column. The server records neither: there is no
|
||||
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
|
||||
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
|
||||
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
|
||||
/// </remarks>
|
||||
internal string KeyState => Member.IsEnrolled
|
||||
? "key published"
|
||||
: "no key yet — cannot be given a vault";
|
||||
|
||||
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
|
||||
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
|
||||
{
|
||||
/// <summary>What the row says about itself.</summary>
|
||||
/// <remarks>
|
||||
/// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
|
||||
/// somebody has been added to a team and nobody has wrapped the vault key to them yet.
|
||||
/// </remarks>
|
||||
internal string State => (IsReadable, RekeyRequired) switch
|
||||
{
|
||||
(false, _) => "waiting for a key — ask a member who has one to share it",
|
||||
(true, true) => "readable · a rekey is owed after a membership change",
|
||||
_ => "readable",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two separate acts, and the screen is built around saying so.</b> Adding somebody to a team is a
|
||||
/// server-side authorization change and takes effect immediately. Giving them a vault key is a
|
||||
/// cryptographic act only a machine with that key can perform, and until somebody does it their vault
|
||||
/// list shows an entry they cannot open. Every product that hides this ends up implying the server can
|
||||
/// hand out access on its own — which, here, it cannot. See <c>TeamService</c> and ADR 0001.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
|
||||
/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
|
||||
/// would be a second copy of something the server is authoritative for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TeamsViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
Func<VaultSession?> session) : ObservableObject
|
||||
{
|
||||
/// <summary>Teams this account belongs to.</summary>
|
||||
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
||||
|
||||
/// <summary>Members of the selected team.</summary>
|
||||
internal ObservableCollection<TeamMemberRowViewModel> Members { get; } = [];
|
||||
|
||||
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
|
||||
internal ObservableCollection<TeamVaultRowViewModel> Vaults { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamRowViewModel? selectedTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamMemberRowViewModel? selectedMember;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamVaultRowViewModel? selectedVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
// ---- Creating a team ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCreatingTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamSlug = string.Empty;
|
||||
|
||||
// ---- Adding a member ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string inviteEmail = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a server to talk to at all.</summary>
|
||||
internal bool IsOnline => connection() is not null;
|
||||
|
||||
/// <summary>Whether the selected team can be administered by this account.</summary>
|
||||
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
|
||||
|
||||
/// <summary>Whether there is anything to show below the team list.</summary>
|
||||
internal bool HasSelection => SelectedTeam is not null;
|
||||
|
||||
internal bool HasTeams => Teams.Count > 0;
|
||||
|
||||
/// <summary>Reads the teams this account belongs to, and the selected one's detail.</summary>
|
||||
internal Task LoadAsync(CancellationToken cancellationToken) =>
|
||||
RunAsync(() => ReloadAsync(cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// The reload itself, without the busy gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="LoadAsync"/> because every command ends by reloading, and a command that
|
||||
/// called the gated version would find the gate held by itself and skip the reload silently — leaving
|
||||
/// a team that was created moments ago missing from the list it was just added to.
|
||||
/// </remarks>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Teams.Clear();
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
RaiseState();
|
||||
|
||||
Status = "Offline. Teams are read from the server, so this screen needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedId = SelectedTeam?.TeamId;
|
||||
|
||||
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Teams.Clear();
|
||||
|
||||
foreach (var team in teams)
|
||||
{
|
||||
Teams.Add(new TeamRowViewModel(team));
|
||||
}
|
||||
|
||||
SelectedTeam =
|
||||
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
||||
|
||||
RaiseState();
|
||||
|
||||
await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = Teams.Count == 0
|
||||
? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void NewTeam()
|
||||
{
|
||||
NewTeamName = string.Empty;
|
||||
NewTeamSlug = string.Empty;
|
||||
IsCreatingTeam = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelNewTeam()
|
||||
{
|
||||
IsCreatingTeam = false;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates a team, with this account as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// The id is generated here, which is what makes a create whose response was lost safe to send again —
|
||||
/// the server treats an identical repeat as the same team rather than a second one.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CreateTeamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Status = "Offline. Creating a team needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewTeamName.Trim();
|
||||
var slug = NewTeamSlug.Trim().ToLowerInvariant();
|
||||
|
||||
if (name.Length == 0 || slug.Length == 0)
|
||||
{
|
||||
Status = "A team needs a name and a slug.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var created = await server.Teams
|
||||
.CreateTeamAsync(
|
||||
new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
IsCreatingTeam = false;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
|
||||
|
||||
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
||||
+ "whoever needs it.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a member, by looking their address up in the directory first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two calls rather than one, and the order is the point: the directory is what turns an address into
|
||||
/// an account and a public key, and the key that gets verified before any sharing is the one that
|
||||
/// lookup returned. Letting the server resolve an address to an account inside the add would put an
|
||||
/// unwitnessed step between the two.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AddMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var email = InviteEmail.Trim();
|
||||
|
||||
if (email.Length == 0)
|
||||
{
|
||||
Status = "Type the email address of somebody who has signed in to this server.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (found.Count == 0)
|
||||
{
|
||||
Status = $"No account here has the address '{email}'. They have to sign in to this "
|
||||
+ "server once before they can be added — that is what publishes the key a vault "
|
||||
+ "would be shared with.";
|
||||
return;
|
||||
}
|
||||
|
||||
var member = await server.Teams
|
||||
.AddTeamMemberAsync(
|
||||
team.TeamId,
|
||||
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Said out loud, every time. The single most common misunderstanding this design invites is
|
||||
// that adding somebody gave them the vault.
|
||||
Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
|
||||
+ "cannot read anything yet — select a vault below and share its key.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedTeam is not { } team
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await server.Teams
|
||||
.RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
|
||||
// and a message implying otherwise is the one thing this screen must not say.
|
||||
Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything "
|
||||
+ "they had already downloaded is still on their machine — rotate the credentials that "
|
||||
+ "matter.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by the selected team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
|
||||
+ "hosts and credentials can be filed into it from the Vault screen.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the selected vault's key to the selected member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything that makes this safe happens inside <see cref="VaultSession.ShareVaultAsync"/>: the key
|
||||
/// log is read and its chain verified, and the directory's answer has to appear in it unchanged before
|
||||
/// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because
|
||||
/// the reasons are not interchangeable — one of them means somebody is substituting keys.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ShareVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.IsSelf)
|
||||
{
|
||||
Status = "You already hold this vault's key.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var outcome = await open
|
||||
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = outcome.Shared
|
||||
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
|
||||
: $"Did not share '{vault.Name}': {outcome.Message}";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Withdraws the selected member's key to the selected vault.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RevokeVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var revoked = await server.Grants
|
||||
.RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = revoked
|
||||
? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they "
|
||||
+ "already have is unaffected."
|
||||
: $"{member.Name} held no key to '{vault.Name}'.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnSelectedTeamChanged(TeamRowViewModel? value)
|
||||
{
|
||||
RaiseState();
|
||||
|
||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||
// from a list box, which has no cancellation token and no way to await. Failures land in Status
|
||||
// through RunAsync exactly as a command's would.
|
||||
_ = LoadSelectedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Reads the selected team's members and vaults.</summary>
|
||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var open = session();
|
||||
var selfId = open?.Profile.UserId;
|
||||
|
||||
var members = await server.Teams
|
||||
.ListTeamMembersAsync(team.TeamId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
|
||||
}
|
||||
|
||||
if (open is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Read from the session rather than from a team-vaults endpoint, because the interesting fact
|
||||
// about a team vault here is whether *this* machine can open it — which is a property of the
|
||||
// keyring and not something the server can answer.
|
||||
var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
|
||||
|
||||
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
|
||||
{
|
||||
Vaults.Add(new TeamVaultRowViewModel(
|
||||
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
|
||||
}
|
||||
|
||||
SelectedVault = Vaults.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void RaiseState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTeams));
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanAdministerSelected));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One place that raises the busy flag and turns a failure into a sentence. An API exception's message
|
||||
/// is the server's problem detail, which is written for a person to read — see <c>Problems</c> — so it
|
||||
/// is shown rather than replaced with something vaguer.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,24 @@ using System.Globalization;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.ObjectStore;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
|
||||
internal enum RemoteKind
|
||||
{
|
||||
/// <summary>A host, over SFTP.</summary>
|
||||
Host,
|
||||
|
||||
/// <summary>An S3-compatible bucket.</summary>
|
||||
Bucket,
|
||||
}
|
||||
|
||||
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
|
||||
/// <param name="Name">What the segment is called.</param>
|
||||
/// <param name="Path">The absolute path that reaches it.</param>
|
||||
@@ -240,7 +252,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
private VaultViewModel? vault;
|
||||
private VaultKnownHostStore? knownHosts;
|
||||
private ISftpSession? session;
|
||||
private IRemoteFileStore? session;
|
||||
private ConnectionRecorder? connectionLog;
|
||||
|
||||
/// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
|
||||
private IObjectStoreFactory? objectStores;
|
||||
|
||||
/// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
|
||||
/// <remarks>
|
||||
/// Held rather than rebuilt at close time, because by then the session is being disposed and the host
|
||||
/// row it came from may have been replaced by a background sync. The address is the one that was
|
||||
/// actually dialled, which is the whole point of capturing it at connect.
|
||||
/// </remarks>
|
||||
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
|
||||
@@ -249,7 +274,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
// The supplier answers with whatever session is current at the moment a transfer starts, which is
|
||||
// what lets a queue survive a disconnect and reconnect without every queued row failing.
|
||||
queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
|
||||
queue = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
|
||||
queue.Changed += OnTransferChanged;
|
||||
|
||||
// The three "is there anything in it" flags follow their collections rather than being raised by
|
||||
@@ -271,6 +296,49 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
|
||||
/// <inheritdoc cref="Hosts" path="/remarks" />
|
||||
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ObjectStoreRowViewModel? selectedBucket;
|
||||
|
||||
/// <summary>
|
||||
/// Which sort of remote the right-hand pane is about to open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
|
||||
/// the host editor's authentication picker does — and the reason is that these two are not
|
||||
/// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
|
||||
/// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
|
||||
/// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
|
||||
/// to look at than two clearly separate choices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
|
||||
/// keychain's categories do: a selection binding moves before a command could refuse it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private RemoteKind remote;
|
||||
|
||||
/// <summary>Whether the picker is showing hosts.</summary>
|
||||
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
|
||||
|
||||
/// <summary>Whether the picker is showing buckets.</summary>
|
||||
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
|
||||
|
||||
/// <summary>
|
||||
/// What the button that opens the remote says.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
|
||||
/// connected and nothing stays open. A word that implied otherwise would make the absence of a
|
||||
/// DISCONNECT step look like a bug rather than the shape of the protocol.
|
||||
/// </remarks>
|
||||
internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
|
||||
|
||||
/// <remarks>
|
||||
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
|
||||
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
|
||||
@@ -288,6 +356,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
/// <summary>
|
||||
/// Whether something is being dragged over the local pane, and whether it would be accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
|
||||
/// three-way binding — and because the refusing state is worth showing rather than merely not showing
|
||||
/// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
|
||||
/// window that has stopped responding.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isLocalDropTarget;
|
||||
|
||||
/// <inheritdoc cref="IsLocalDropTarget" />
|
||||
[ObservableProperty]
|
||||
private bool isRemoteDropTarget;
|
||||
|
||||
/// <inheritdoc cref="IsLocalDropTarget" />
|
||||
[ObservableProperty]
|
||||
private bool isRemoteDropRefused;
|
||||
|
||||
/// <summary>The account and endpoint actually dialled, once connected.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
@@ -304,7 +392,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
ShowsHostPicker && SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
@@ -376,10 +464,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
|
||||
internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys)
|
||||
/// <param name="openVault">The open keychain.</param>
|
||||
/// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
|
||||
/// <param name="log">
|
||||
/// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
|
||||
/// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
|
||||
/// when the keychain locks still ends somewhere.
|
||||
/// </param>
|
||||
internal void Attach(
|
||||
VaultViewModel openVault,
|
||||
VaultKnownHostStore hostKeys,
|
||||
ConnectionRecorder? log = null,
|
||||
IObjectStoreFactory? buckets = null)
|
||||
{
|
||||
vault = openVault;
|
||||
knownHosts = hostKeys;
|
||||
connectionLog = log;
|
||||
objectStores = buckets;
|
||||
|
||||
RefreshHosts();
|
||||
|
||||
@@ -408,13 +509,78 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
knownHosts = null;
|
||||
|
||||
Hosts.Clear();
|
||||
Buckets.Clear();
|
||||
SelectedHost = null;
|
||||
SelectedBucket = null;
|
||||
TypedPassword = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
/// <summary>Shows one of the two kinds of remote in the picker.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
private void ShowRemote(RemoteKind kind) => Remote = kind;
|
||||
|
||||
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
|
||||
[RelayCommand]
|
||||
private Task ConnectAsync(CancellationToken cancellationToken) =>
|
||||
Remote is RemoteKind.Bucket
|
||||
? OpenBucketAsync(cancellationToken)
|
||||
: ConnectToHostAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the chosen bucket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
|
||||
/// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
|
||||
/// why the failure this reports is a listing failure rather than a connection one — there is no
|
||||
/// connection to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It goes through the same session field, the same queue and the same panes as a host, because by this
|
||||
/// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
|
||||
/// SFTP and needed no change.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task OpenBucketAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (objectStores is not { } factory)
|
||||
{
|
||||
Status = "This build cannot open buckets.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedBucket is not { } row)
|
||||
{
|
||||
Status = "Choose a bucket first.";
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Opening {row.Label}…",
|
||||
async () =>
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
session = factory.Open(row.Store);
|
||||
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
||||
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Opened {row.Label}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
private async Task ConnectToHostAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (vault is not { } open || SelectedHost is not { } row)
|
||||
{
|
||||
@@ -463,6 +629,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
|
||||
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
|
||||
// would disagree with the host's — and anybody comparing the two would be right to believe
|
||||
// the host.
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
@@ -624,34 +796,147 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[RelayCommand]
|
||||
private void Download()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { IsFile: true } row)
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
Status = "Choose a file on the host to download.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = Path.Combine(LocalPath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for download into {LocalPath}.";
|
||||
QueueDownloads([row]);
|
||||
}
|
||||
|
||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Upload()
|
||||
{
|
||||
if (SelectedLocalEntry is not { IsFile: true } row)
|
||||
if (SelectedLocalEntry is not { } row)
|
||||
{
|
||||
Status = "Choose a file on this machine to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = SftpPath.Combine(RemotePath, row.Name);
|
||||
QueueUploads([row.FullPath]);
|
||||
}
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
|
||||
/// <summary>
|
||||
/// Queues every one of these local paths for upload into the remote directory showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one path both the button and a drop go through, so there is one set of rules about what can be
|
||||
/// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
|
||||
/// were dragged, from this window's own pane or from the file manager.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
|
||||
/// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
|
||||
/// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
|
||||
/// about all five.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void QueueUploads(IReadOnlyList<string> paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
Status = $"Queued {row.Name} for upload into {RemotePath}.";
|
||||
if (!IsConnected)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var queued = 0;
|
||||
var directories = 0;
|
||||
var missing = 0;
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
directories++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Between the drag starting and the drop landing, a file can be moved or deleted — and the
|
||||
// paths in an OS drop come from another process, which is not obliged to be right about them.
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = new FileInfo(path).Length;
|
||||
var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, path, destination, length);
|
||||
queued++;
|
||||
}
|
||||
|
||||
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
||||
}
|
||||
|
||||
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
||||
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var queued = 0;
|
||||
var directories = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!row.IsFile)
|
||||
{
|
||||
directories++;
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.Enqueue(
|
||||
TransferDirection.Download,
|
||||
Path.Combine(LocalPath, row.Name),
|
||||
row.FullPath,
|
||||
row.Entry.Length);
|
||||
|
||||
queued++;
|
||||
}
|
||||
|
||||
Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One sentence for both directions and every shape of partial success. What it must never do is stay
|
||||
/// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
|
||||
/// looking for the other two in a queue they are not in.
|
||||
/// </remarks>
|
||||
private static string Describe(int queued, string verb, string destination, int directories, int missing)
|
||||
{
|
||||
var files = queued == 1 ? "1 file" : $"{queued} files";
|
||||
var said = queued == 0
|
||||
? "Nothing was queued."
|
||||
: $"Queued {files} for {verb} {destination}.";
|
||||
|
||||
if (directories > 0)
|
||||
{
|
||||
var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
|
||||
said += $" {folders} skipped — only files can be transferred.";
|
||||
}
|
||||
|
||||
if (missing > 0)
|
||||
{
|
||||
var gone = missing == 1 ? "1 item was" : $"{missing} items were";
|
||||
said += $" {gone} no longer there.";
|
||||
}
|
||||
|
||||
return said;
|
||||
}
|
||||
|
||||
/// <summary>Stops one transfer.</summary>
|
||||
@@ -919,10 +1204,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
SelectedHost ??= Hosts.FirstOrDefault();
|
||||
|
||||
Buckets.Clear();
|
||||
|
||||
foreach (var bucket in open.ObjectStores)
|
||||
{
|
||||
Buckets.Add(bucket);
|
||||
}
|
||||
|
||||
SelectedBucket ??= Buckets.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
private ISftpSession RequireSession() =>
|
||||
private IRemoteFileStore RequireSession() =>
|
||||
session ?? throw new InvalidOperationException(
|
||||
"This screen is not connected to a host, so there is nowhere to move the file.");
|
||||
|
||||
@@ -934,6 +1228,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// Written whole here rather than through an open/close ticket, because this connection is not one
|
||||
// the terminal workspace ever knew about — it has no session id, and borrowing one would collide
|
||||
// with a real terminal's.
|
||||
if (connected is { } record)
|
||||
{
|
||||
connected = null;
|
||||
|
||||
connectionLog?.Record(
|
||||
record.Address,
|
||||
record.HostLabel,
|
||||
record.HostId,
|
||||
ConnectionKind.Sftp,
|
||||
record.StartedAt,
|
||||
TimeProvider.System.GetUtcNow(),
|
||||
ConnectionOutcome.Closed);
|
||||
}
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
RemotePath = string.Empty;
|
||||
@@ -996,6 +1307,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
|
||||
/// <remarks>
|
||||
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
||||
/// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
|
||||
/// picker that has nothing to do with passwords.
|
||||
/// </remarks>
|
||||
partial void OnRemoteChanged(RemoteKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsHostPicker));
|
||||
OnPropertyChanged(nameof(ShowsBucketPicker));
|
||||
OnPropertyChanged(nameof(ConnectLabel));
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
}
|
||||
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ const SERVER_SESSION_OPENED = 2;
|
||||
const SERVER_SESSION_CLOSED = 3;
|
||||
const SERVER_SESSION_ACTIVATED = 4;
|
||||
const SERVER_SESSION_REMOVED = 5;
|
||||
const SERVER_PASTE = 6;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
@@ -275,6 +276,39 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_PASTE: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session || payload.length < 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const execute = payload[0] !== 0;
|
||||
const text = new TextDecoder().decode(payload.subarray(1));
|
||||
|
||||
/*
|
||||
term.paste rather than term.input, and that is the whole reason this frame exists rather than
|
||||
the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
|
||||
when the remote has turned that mode on — xterm tracks \e[?2004h from the output stream, which
|
||||
is something only this page sees — and a shell that receives a multi-line command inside those
|
||||
markers treats every newline as text. Without them it treats each one as "run this", so a
|
||||
three-line snippet runs three commands the moment it is inserted.
|
||||
*/
|
||||
session.term.paste(text);
|
||||
|
||||
/*
|
||||
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
||||
pasted text would be bracketed along with it and arrive at the shell as a literal carriage
|
||||
return, so nothing would run — which is the failure that looks like the feature working right
|
||||
up until somebody wonders why RUN does not.
|
||||
*/
|
||||
if (execute) {
|
||||
session.term.input('\r');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_CLOSED: {
|
||||
const session = sessions.get(sessionId);
|
||||
const reason = new TextDecoder().decode(payload);
|
||||
|
||||
@@ -170,6 +170,21 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.import": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.objectstore": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, )",
|
||||
"AWSSDK.S3": "[4.0.101.6, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -178,12 +193,14 @@
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -227,6 +244,21 @@
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
|
||||
@@ -7,6 +7,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SSH.NET" />
|
||||
<!--
|
||||
For Ed25519 key generation, which .NET has no primitive for at all. The same reason DodoSSH.Crypto
|
||||
takes it; see Directory.Packages.props. Nothing else here touches it, and no key generated with it is
|
||||
part of the DSH1 envelope — this is an SSH file format, not the vault's cryptography.
|
||||
-->
|
||||
<PackageReference Include="NSec.Cryptography" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
using System.Buffers.Binary;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>
|
||||
/// Writes the two files <c>ssh-keygen</c> writes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why this is hand-rolled.</b> Neither the BCL nor NSec can write an OpenSSH private key. .NET has no
|
||||
/// Ed25519 at all — that is why NSec is here in the first place — and the <c>openssh-key-v1</c> container is
|
||||
/// an SSH-specific framing that no general-purpose library emits. The one alternative was PKCS#8 with the
|
||||
/// Ed25519 OID <c>1.3.101.112</c>, which would also have had to be hand-encoded and which SSH.NET 2025.1.0
|
||||
/// is not confirmed to parse — its PKCS#8 path historically switches on RSA, DSA and EC OIDs only. This
|
||||
/// format is the one SSH.NET definitely reads and the one every other tool reads.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The container is written unencrypted, on purpose.</b> Encrypting one needs <c>bcrypt_pbkdf</c> —
|
||||
/// Blowfish with a byte-swizzling quirk — plus AES-256-CTR. .NET has no Blowfish, <c>Rfc2898DeriveBytes</c>
|
||||
/// is in <c>BannedSymbols.txt</c> and is the wrong primitive anyway, and the only oracle for a hand-written
|
||||
/// implementation is <c>ssh-keygen</c> itself. That is a standing crypto maintenance cost for a defence the
|
||||
/// product does not need: a passphrase protects a key file sitting on a disk, and a key generated here goes
|
||||
/// straight into an end-to-end encrypted keychain and never touches one. See
|
||||
/// <c>SshKeySecret.Passphrase</c>, which makes the same argument at more length.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Everything below is length-prefixed big-endian, which is the whole of the SSH wire format. Getting a
|
||||
/// prefix wrong yields a file that parses far enough to look plausible and then fails authentication with
|
||||
/// an error that says nothing about the encoding.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class OpenSshKeyWriter
|
||||
{
|
||||
private const string Magic = "openssh-key-v1\0";
|
||||
|
||||
private const string Ed25519Algorithm = "ssh-ed25519";
|
||||
|
||||
private const string RsaAlgorithm = "ssh-rsa";
|
||||
|
||||
/// <summary>
|
||||
/// How wide the base64 body is wrapped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// OpenSSH writes 70. Nothing parses by line length — but a key that diffs against one <c>ssh-keygen</c>
|
||||
/// produced, in a repository or a paste, should differ in its bytes and not in its wrapping.
|
||||
/// </remarks>
|
||||
private const int WrapAt = 70;
|
||||
|
||||
/// <summary>
|
||||
/// The armoured private key for an Ed25519 pair, in <c>openssh-key-v1</c> form.
|
||||
/// </summary>
|
||||
/// <param name="seed">The 32-byte private scalar seed, as NSec exports it.</param>
|
||||
/// <param name="publicKey">The 32-byte public point.</param>
|
||||
/// <param name="comment">The trailing comment, which OpenSSH stores inside the private section.</param>
|
||||
internal static string WriteEd25519PrivateKey(
|
||||
ReadOnlySpan<byte> seed,
|
||||
ReadOnlySpan<byte> publicKey,
|
||||
string comment)
|
||||
{
|
||||
var publicBlob = Ed25519PublicBlob(publicKey);
|
||||
|
||||
using var privateSection = new MemoryStream();
|
||||
|
||||
// Two copies of the same random value. OpenSSH uses them as a decryption check: after decrypting an
|
||||
// encrypted key it compares them, and a mismatch is a wrong passphrase. Nothing here is encrypted,
|
||||
// so nothing checks them — they are written because the format says so, and a parser is entitled to
|
||||
// insist.
|
||||
var check = RandomNumberGenerator.GetBytes(4);
|
||||
privateSection.Write(check);
|
||||
privateSection.Write(check);
|
||||
|
||||
WriteString(privateSection, Ed25519Algorithm);
|
||||
WriteString(privateSection, publicKey);
|
||||
|
||||
// The private field of an Ed25519 OpenSSH key is the seed followed by the public point, 64 bytes,
|
||||
// not the 32-byte seed alone. A file carrying only the seed loads and then signs with a key whose
|
||||
// public half nobody agrees on.
|
||||
Span<byte> expanded = stackalloc byte[64];
|
||||
seed.CopyTo(expanded);
|
||||
publicKey.CopyTo(expanded[32..]);
|
||||
WriteString(privateSection, expanded);
|
||||
|
||||
WriteString(privateSection, comment);
|
||||
|
||||
Pad(privateSection);
|
||||
|
||||
using var container = new MemoryStream();
|
||||
container.Write(Encoding.ASCII.GetBytes(Magic));
|
||||
WriteString(container, "none");
|
||||
WriteString(container, "none");
|
||||
WriteString(container, ReadOnlySpan<byte>.Empty);
|
||||
WriteUInt32(container, 1);
|
||||
WriteString(container, publicBlob);
|
||||
WriteString(container, privateSection.ToArray());
|
||||
|
||||
return Armour("OPENSSH PRIVATE KEY", container.ToArray());
|
||||
}
|
||||
|
||||
/// <summary>The <c>authorized_keys</c> line for an Ed25519 public point.</summary>
|
||||
internal static string WriteEd25519PublicKey(ReadOnlySpan<byte> publicKey, string comment) =>
|
||||
PublicLine(Ed25519Algorithm, Ed25519PublicBlob(publicKey), comment);
|
||||
|
||||
/// <summary>The <c>authorized_keys</c> line for an RSA key.</summary>
|
||||
internal static string WriteRsaPublicKey(RSA rsa, string comment) =>
|
||||
PublicLine(RsaAlgorithm, RsaPublicBlob(rsa), comment);
|
||||
|
||||
/// <summary>The raw public key blob, which is what a fingerprint is taken over.</summary>
|
||||
internal static byte[] Ed25519PublicBlob(ReadOnlySpan<byte> publicKey)
|
||||
{
|
||||
using var blob = new MemoryStream();
|
||||
WriteString(blob, Ed25519Algorithm);
|
||||
WriteString(blob, publicKey);
|
||||
|
||||
return blob.ToArray();
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="Ed25519PublicBlob" />
|
||||
internal static byte[] RsaPublicBlob(RSA rsa)
|
||||
{
|
||||
var parameters = rsa.ExportParameters(includePrivateParameters: false);
|
||||
|
||||
using var blob = new MemoryStream();
|
||||
WriteString(blob, RsaAlgorithm);
|
||||
WriteMpint(blob, parameters.Exponent!);
|
||||
WriteMpint(blob, parameters.Modulus!);
|
||||
|
||||
return blob.ToArray();
|
||||
}
|
||||
|
||||
private static string PublicLine(string algorithm, byte[] blob, string comment)
|
||||
{
|
||||
var line = $"{algorithm} {Convert.ToBase64String(blob)}";
|
||||
|
||||
return string.IsNullOrWhiteSpace(comment) ? line : $"{line} {comment.Trim()}";
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// To a multiple of eight, with the bytes 1, 2, 3… — the block size of the "none" cipher, which OpenSSH
|
||||
/// applies even though nothing is being blocked. This is the classic place to get an
|
||||
/// <c>openssh-key-v1</c> writer wrong, because whether it is wrong depends on the length of the comment:
|
||||
/// a name that happens to land on a boundary produces a file that loads everywhere, and one character
|
||||
/// more produces one that does not.
|
||||
/// </remarks>
|
||||
private static void Pad(Stream destination)
|
||||
{
|
||||
var remainder = (int)(destination.Length % 8);
|
||||
|
||||
if (remainder == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
for (var i = 1; i <= 8 - remainder; i++)
|
||||
{
|
||||
destination.WriteByte((byte)i);
|
||||
}
|
||||
}
|
||||
|
||||
private static string Armour(string label, byte[] body)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.Append("-----BEGIN ").Append(label).Append("-----\n");
|
||||
|
||||
var base64 = Convert.ToBase64String(body);
|
||||
|
||||
for (var offset = 0; offset < base64.Length; offset += WrapAt)
|
||||
{
|
||||
builder.Append(base64.AsSpan(offset, Math.Min(WrapAt, base64.Length - offset))).Append('\n');
|
||||
}
|
||||
|
||||
builder.Append("-----END ").Append(label).Append("-----\n");
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
private static void WriteString(Stream destination, string value) =>
|
||||
WriteString(destination, Encoding.UTF8.GetBytes(value));
|
||||
|
||||
private static void WriteString(Stream destination, ReadOnlySpan<byte> value)
|
||||
{
|
||||
WriteUInt32(destination, (uint)value.Length);
|
||||
destination.Write(value);
|
||||
}
|
||||
|
||||
private static void WriteUInt32(Stream destination, uint value)
|
||||
{
|
||||
Span<byte> encoded = stackalloc byte[4];
|
||||
BinaryPrimitives.WriteUInt32BigEndian(encoded, value);
|
||||
destination.Write(encoded);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Signed big-endian, so a leading byte with its high bit set needs a zero in front of it or it reads as
|
||||
/// a negative number. An RSA modulus has that bit set roughly half the time, which is what makes this
|
||||
/// the kind of bug that ships.
|
||||
/// </remarks>
|
||||
private static void WriteMpint(Stream destination, byte[] value)
|
||||
{
|
||||
if (value.Length > 0 && (value[0] & 0x80) != 0)
|
||||
{
|
||||
var padded = new byte[value.Length + 1];
|
||||
value.CopyTo(padded, 1);
|
||||
WriteString(destination, padded);
|
||||
return;
|
||||
}
|
||||
|
||||
WriteString(destination, value);
|
||||
}
|
||||
}
|
||||
@@ -261,14 +261,40 @@ public static class SftpPath
|
||||
/// transfer runs is fine, and is the point of not opening a session per transfer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISftpSession : IAsyncDisposable
|
||||
public interface ISftpSession : IRemoteFileStore
|
||||
{
|
||||
/// <summary>The host key that was accepted for this session.</summary>
|
||||
HostKeyPresentation HostKey { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A remote place with files in it, whatever protocol reaches it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Extracted from <see cref="ISftpSession"/> when buckets arrived, and unchanged in shape — the transfer
|
||||
/// queue reads, writes, stats and lists, and never once needed anything SSH-specific. What stayed behind on
|
||||
/// <c>ISftpSession</c> is the one member that could not be answered by a bucket: a host key.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It lives in a project called <c>.Ssh</c>, which is a naming debt worth writing down rather than
|
||||
/// paying.</b> <see cref="SftpEntry"/> is here too and is the type every listing is made of, so moving the
|
||||
/// interface without moving that would split the vocabulary in half — and moving both means renaming a
|
||||
/// record that the whole file browser and its tests are written against. The cost of leaving it is a
|
||||
/// reference that reads oddly from the object-store project; the cost of moving it is a rename with no
|
||||
/// behaviour in it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not every implementation can do everything, and the contract says which.</b> An object store has no
|
||||
/// directories, no rename and no way to resume a half-finished upload; each of those is documented on the
|
||||
/// member and refused with a reason rather than silently approximated. See <c>S3FileStore</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IRemoteFileStore : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Whether the transport is still up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>The host key that was accepted for this session.</summary>
|
||||
HostKeyPresentation HostKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the session starts, which is the account's home directory.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Security.Cryptography;
|
||||
using NSec.Cryptography;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>Which kind of key pair to make.</summary>
|
||||
public enum SshKeyAlgorithm
|
||||
{
|
||||
/// <summary>Ed25519. Small, fast, and what every current OpenSSH prefers.</summary>
|
||||
Ed25519 = 0,
|
||||
|
||||
/// <summary>RSA at 4096 bits, for servers too old to accept the above.</summary>
|
||||
Rsa4096 = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A freshly generated key pair, in the two forms anybody needs it in.
|
||||
/// </summary>
|
||||
/// <param name="PrivateKeyArmour">
|
||||
/// The private half, in the armoured form <c>ssh-keygen</c> writes. Goes straight into
|
||||
/// <c>SshKeySecret.PrivateKeyPem</c>, which stores it verbatim.
|
||||
/// </param>
|
||||
/// <param name="PublicKeyLine">
|
||||
/// The public half, as one <c>authorized_keys</c> line. This is what gets installed on a host.
|
||||
/// </param>
|
||||
/// <param name="Fingerprint">
|
||||
/// The <c>SHA256:…</c> fingerprint, in the format <c>ssh-keygen -lf</c> prints, so it can be read out to
|
||||
/// somebody or compared against what a host reports.
|
||||
/// </param>
|
||||
public sealed record GeneratedSshKey(string PrivateKeyArmour, string PublicKeyLine, string Fingerprint);
|
||||
|
||||
/// <summary>
|
||||
/// Makes a new SSH key pair without shelling out to <c>ssh-keygen</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why the client can do this at all.</b> Every part is already here: NSec does Ed25519 because .NET
|
||||
/// does not, the BCL does RSA, and the SSH wire encoding is a few length-prefixed strings — see
|
||||
/// <see cref="OpenSshKeyWriter"/>. What it buys is that the private key is never written to a disk. The
|
||||
/// alternative flow is "run ssh-keygen, find the file, open it, copy the text, paste it here, remember to
|
||||
/// delete the file", and the last step is the one nobody does.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The armour has no passphrase</b>, and that is a deliberate limitation with its reasoning in
|
||||
/// <see cref="OpenSshKeyWriter"/>. The key is protected by the keychain it lands in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This lives in the SSH project rather than in <c>DodoSSH.Crypto</c>, which is the normative
|
||||
/// implementation of <c>docs/crypto.md</c> and has nothing to say about SSH file formats. It is also where
|
||||
/// <see cref="SshHostKeyFingerprint"/> already lives, and a second <c>SHA256:</c> encoder would be a second
|
||||
/// thing to get wrong.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SshKeyGenerator
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates a key pair.
|
||||
/// </summary>
|
||||
/// <param name="algorithm">Which kind.</param>
|
||||
/// <param name="comment">
|
||||
/// The trailing comment, conventionally <c>user@machine</c>. It identifies the key in a host's
|
||||
/// <c>authorized_keys</c> and is the only thing there that will say where it came from.
|
||||
/// </param>
|
||||
/// <remarks>
|
||||
/// Synchronous and CPU-bound. RSA at 4096 bits is seconds of work on an ordinary machine, so a caller on
|
||||
/// a UI thread has to move this to one of its own — the window would otherwise freeze at exactly the
|
||||
/// moment somebody is watching it. Ed25519 is effectively instant, and the caller should not have to
|
||||
/// know which is which.
|
||||
/// </remarks>
|
||||
public static GeneratedSshKey Generate(SshKeyAlgorithm algorithm, string comment) => algorithm switch
|
||||
{
|
||||
SshKeyAlgorithm.Ed25519 => Ed25519(comment),
|
||||
SshKeyAlgorithm.Rsa4096 => Rsa4096(comment),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(algorithm)),
|
||||
};
|
||||
|
||||
private static GeneratedSshKey Ed25519(string comment)
|
||||
{
|
||||
var parameters = new KeyCreationParameters
|
||||
{
|
||||
// The seed has to come back out to be written into the file. NSec holds key material in
|
||||
// libsodium's guarded memory and refuses to export it unless asked at creation time.
|
||||
ExportPolicy = KeyExportPolicies.AllowPlaintextExport,
|
||||
};
|
||||
|
||||
using var key = Key.Create(SignatureAlgorithm.Ed25519, parameters);
|
||||
|
||||
var seed = key.Export(KeyBlobFormat.RawPrivateKey);
|
||||
|
||||
try
|
||||
{
|
||||
var publicKey = key.PublicKey.Export(KeyBlobFormat.RawPublicKey);
|
||||
|
||||
return new GeneratedSshKey(
|
||||
OpenSshKeyWriter.WriteEd25519PrivateKey(seed, publicKey, comment),
|
||||
OpenSshKeyWriter.WriteEd25519PublicKey(publicKey, comment),
|
||||
SshHostKeyFingerprint.Format(OpenSshKeyWriter.Ed25519PublicBlob(publicKey)));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The one copy of the private scalar this method makes, and it is an ordinary managed array
|
||||
// outside libsodium's guarded memory. Clearing it does not undo anything the garbage collector
|
||||
// may already have moved, which is why the export happens once and is used immediately.
|
||||
CryptographicOperations.ZeroMemory(seed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// PKCS#1, which is what <c>ExportRSAPrivateKeyPem</c> writes and what SSH.NET's <c>RSA PRIVATE KEY</c>
|
||||
/// branch reads. No hand-encoding is needed on this path at all — only the public line, because there is
|
||||
/// no BCL helper for the SSH wire format.
|
||||
/// </remarks>
|
||||
private static GeneratedSshKey Rsa4096(string comment)
|
||||
{
|
||||
using var rsa = RSA.Create(4096);
|
||||
|
||||
return new GeneratedSshKey(
|
||||
rsa.ExportRSAPrivateKeyPem() + "\n",
|
||||
OpenSshKeyWriter.WriteRsaPublicKey(rsa, comment),
|
||||
SshHostKeyFingerprint.Format(OpenSshKeyWriter.RsaPublicBlob(rsa)));
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,15 @@
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"NSec.Cryptography": {
|
||||
"type": "Direct",
|
||||
"requested": "[26.4.0, )",
|
||||
"resolved": "26.4.0",
|
||||
"contentHash": "0vsCtY5f+YgQROiWNqzgWp+l2pddfk9FkWoGV/bEo0MuEYPKlJWuoA8aOfO6qp3f+EnObKE3zSJhn1PspJeJVg==",
|
||||
"dependencies": {
|
||||
"libsodium": "[1.0.22, 1.0.23)"
|
||||
}
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "Direct",
|
||||
"requested": "[2025.1.0, )",
|
||||
@@ -42,6 +51,12 @@
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"libsodium": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[1.0.22, )",
|
||||
"resolved": "1.0.22",
|
||||
"contentHash": "KPD9SloJFclrsjnhABu7dzWrcyYkwPbvx5l1gRSPAX/0n+OBtSiVCKtGFv4n+ecWUHU0tCG9LSSwoZZx673zBQ=="
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user