Public Access
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
656a33b4b1 | ||
|
|
41f368ae75 | ||
|
|
24c18205c3 | ||
|
|
8759b12d47 | ||
|
|
7b072bcded | ||
|
|
b1d4a270d4 | ||
|
|
a2362d1e3e | ||
|
|
079a273d46 | ||
|
|
37e9d74938 | ||
|
|
170caa1391 | ||
|
|
965c9a7043 | ||
|
|
ff8a790f6c | ||
|
|
b2516ca7fb | ||
|
|
36fb0817a4 | ||
|
|
cc3d46f78c | ||
|
|
8bee476039 | ||
|
|
441709f1e9 | ||
|
|
873405e591 | ||
|
|
f997e87c5f | ||
|
|
365954c541 | ||
|
|
3ccafdd53b | ||
|
|
22b4d82ba8 | ||
|
|
619f49a35a | ||
|
|
a6d3924e0d | ||
|
|
079914bfb4 | ||
|
|
6c09bdecb6 | ||
|
|
372a7cbdc7 | ||
|
|
a4b81da597 | ||
|
|
0e8fe4fc80 | ||
|
|
2ff9dff560 | ||
|
|
6846f67c20 | ||
|
|
f332d9a56f | ||
|
|
03c8e8589a | ||
|
|
4c7f47c026 | ||
|
|
412c314aec | ||
|
|
b08f2c58e3 | ||
|
|
ee423b25ce | ||
|
|
38ab9b431b | ||
|
|
07bd5c9886 | ||
|
|
31aca26e89 | ||
|
|
843713b118 | ||
|
|
9c7be8ae9b | ||
|
|
3d0a66f997 | ||
|
|
689ba6c1bf | ||
|
|
6e39807cc0 | ||
|
|
d7d002c972 | ||
|
|
a6a21decbd |
No files matched your search
@@ -1326,6 +1326,19 @@ jobs:
|
|||||||
# inspects — it is the thing that gets deployed.
|
# inspects — it is the thing that gets deployed.
|
||||||
needs: [build]
|
needs: [build]
|
||||||
runs-on: [linux]
|
runs-on: [linux]
|
||||||
|
# Gitea's own container registry, on the forge this workflow runs on, under the same owner as
|
||||||
|
# the repository - so the image is listed with the repository's packages rather than on a
|
||||||
|
# registry of its own. Lowercase, because an image reference may not contain capitals and
|
||||||
|
# the owner is DodoTech-Public.
|
||||||
|
#
|
||||||
|
# REGISTRY_USERNAME and REGISTRY_PASSWORD are a Gitea account and a token of its own with
|
||||||
|
# package read and write, allowed to publish for that owner. Not the job's built-in
|
||||||
|
# GITEA_TOKEN, tempting as it is here: the container registry does not accept an Actions
|
||||||
|
# task token and answers `unauthorized` at /v2/ (go-gitea/gitea#23642). Spelunk's workflow
|
||||||
|
# pushes the same way, with the same two secrets.
|
||||||
|
env:
|
||||||
|
REGISTRY: git.dodotech.cloud
|
||||||
|
IMAGE: dodotech-public/dodossh-api
|
||||||
steps:
|
steps:
|
||||||
# Duplicated from the build job; see the comment there for why it cannot be factored
|
# Duplicated from the build job; see the comment there for why it cannot be factored
|
||||||
# out. Any change here has to be made in all three.
|
# out. Any change here has to be made in all three.
|
||||||
@@ -1451,9 +1464,6 @@ jobs:
|
|||||||
# changes, and where setup-buildx-action starts earning its place.
|
# changes, and where setup-buildx-action starts earning its place.
|
||||||
- name: work out the tags
|
- name: work out the tags
|
||||||
id: tags
|
id: tags
|
||||||
env:
|
|
||||||
REGISTRY: registry-docker.dodotech.cloud
|
|
||||||
IMAGE: dodotech/dodossh-api
|
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
repo="$REGISTRY/$IMAGE"
|
repo="$REGISTRY/$IMAGE"
|
||||||
@@ -1578,7 +1588,7 @@ jobs:
|
|||||||
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
|
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
|
||||||
|
|
||||||
printf '%s' "$REGISTRY_PASSWORD" \
|
printf '%s' "$REGISTRY_PASSWORD" \
|
||||||
| docker login registry-docker.dodotech.cloud \
|
| docker login "$REGISTRY" \
|
||||||
--username "$REGISTRY_USERNAME" --password-stdin
|
--username "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
- name: push
|
- name: push
|
||||||
@@ -1596,7 +1606,7 @@ jobs:
|
|||||||
# does not leave it behind.
|
# does not leave it behind.
|
||||||
- name: log out
|
- name: log out
|
||||||
if: always() && github.event_name != 'pull_request'
|
if: always() && github.event_name != 'pull_request'
|
||||||
run: docker logout registry-docker.dodotech.cloud
|
run: docker logout "$REGISTRY"
|
||||||
|
|
||||||
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||||
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||||
|
|||||||
@@ -452,6 +452,64 @@ says so, and the terminal is unaffected. Any row can be switched off and on whil
|
|||||||
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
||||||
[The Android head](#the-android-head).
|
[The Android head](#the-android-head).
|
||||||
|
|
||||||
|
### Reaching a server through Spelunk
|
||||||
|
|
||||||
|
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
|
||||||
|
that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in
|
||||||
|
Spelunk's API address, the server's id in Spelunk, and your Spelunk API key. A connection then asks Spelunk
|
||||||
|
for a ticket, opens a WebSocket, and the agent connects to the SSH server on the machine's own loopback — so
|
||||||
|
the server needs no open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk
|
||||||
|
are one choice, and ticking one unticks the other.
|
||||||
|
|
||||||
|
- **Fill in the API key once, under Settings → Security → Spelunk.** Give Spelunk's API address and paste
|
||||||
|
the key (`spelunk_…`, with the Admin role). It is saved as a *Spelunk key* in your personal vault —
|
||||||
|
encrypted, synced to your other devices, never shown again — together with the address, and **it is only
|
||||||
|
ever sent to that address**: a host that names the key with any other address is refused at connect. One
|
||||||
|
key serves every server in that Spelunk organisation; saving a key for an address that already has one
|
||||||
|
replaces it, and every host using it connects with the new key. The host editor offers saved keys first
|
||||||
|
and picks the one for the address you type. The API address must be `https://`, except for a Spelunk on
|
||||||
|
this machine.
|
||||||
|
- A key kept the older way, as a credential's password, still works and is still offered in the editor;
|
||||||
|
its password goes to whatever address the host names.
|
||||||
|
- **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it
|
||||||
|
is for every host, and the port is what the agent connects to on the server.
|
||||||
|
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: a key Spelunk will
|
||||||
|
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
|
||||||
|
connected, an agent that could not reach the SSH port or is too old to open terminals.
|
||||||
|
|
||||||
|
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
|
||||||
|
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
|
||||||
|
Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises;
|
||||||
|
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
|
||||||
|
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
|
||||||
|
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
|
||||||
|
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
|
||||||
|
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
|
||||||
|
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
|
||||||
|
so a DodoSSH older than this shows it read-only.
|
||||||
|
|
||||||
|
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
|
||||||
|
link, or hands the link to the copy already running, and **connects straight away.** The link never
|
||||||
|
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
|
||||||
|
|
||||||
|
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
|
||||||
|
- Otherwise, when you have saved a Spelunk key for that Spelunk under Settings → Security — or a saved host
|
||||||
|
already sends an API key there — DodoSSH saves a new host in your personal vault — named as the link
|
||||||
|
names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through Spelunk with that
|
||||||
|
key, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any host
|
||||||
|
you saved yourself.
|
||||||
|
- **A link from a Spelunk with no key** opens the host editor instead, filled in the same way with sign-in
|
||||||
|
ticked and the tag on, and you choose that Spelunk's API key once and save. Every later link from that
|
||||||
|
Spelunk connects straight away. A link can be sent by anything on this machine, so an API key is never
|
||||||
|
sent to an address you have not sent it to — saving the key under Security is how you say you have.
|
||||||
|
|
||||||
|
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
|
||||||
|
its own. A link that arrives while the vault is locked waits for the unlock.
|
||||||
|
|
||||||
|
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
|
||||||
|
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
|
||||||
|
The phone reaches Spelunk hosts the same way but does not answer links yet.
|
||||||
|
|
||||||
### Sharing a vault
|
### Sharing a vault
|
||||||
|
|
||||||
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
||||||
|
|||||||
@@ -247,6 +247,13 @@ ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
|
|||||||
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
||||||
# name deliberately and not the package's binary name — the window does not know which package
|
# name deliberately and not the package's binary name — the window does not know which package
|
||||||
# started it.
|
# started it.
|
||||||
|
#
|
||||||
|
# %%u — RPM's spelling of a literal %u, the field code for "one URL" — and the MimeType line make this
|
||||||
|
# the handler for dodossh:// links, which is how Spelunk's "Open in DodoSSH" reaches the application:
|
||||||
|
# the desktop starts the binary with the link as its one argument, and Program.Main reads and checks
|
||||||
|
# it, or passes it to the instance already running. update-desktop-database in the post-install
|
||||||
|
# scriptlet is what makes xdg-open see the handler. Both channels declare it, so whichever is
|
||||||
|
# installed answers, and with both installed the desktop's own default decides.
|
||||||
mkdir -p %{buildroot}%{_datadir}/applications
|
mkdir -p %{buildroot}%{_datadir}/applications
|
||||||
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
@@ -255,13 +262,14 @@ Version=1.0
|
|||||||
Name=%{dodo_title}
|
Name=%{dodo_title}
|
||||||
GenericName=SSH Client
|
GenericName=SSH Client
|
||||||
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||||
Exec=%{dodo_bin}
|
Exec=%{dodo_bin} %%u
|
||||||
Icon=%{dodo_bin}
|
Icon=%{dodo_bin}
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
StartupWMClass=DodoSSH
|
StartupWMClass=DodoSSH
|
||||||
Categories=Network;RemoteAccess;System;
|
Categories=Network;RemoteAccess;System;
|
||||||
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
||||||
|
MimeType=x-scheme-handler/dodossh;
|
||||||
DESKTOP
|
DESKTOP
|
||||||
|
|
||||||
# One file per size into the hicolor theme, named for the package so the two channels show different
|
# One file per size into the hicolor theme, named for the package so the two channels show different
|
||||||
|
|||||||
@@ -119,6 +119,25 @@
|
|||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
|
||||||
|
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
|
||||||
|
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
|
||||||
|
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
|
||||||
|
for a Velopack install is on first launch from /Applications.
|
||||||
|
-->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>dev.dodotech.dodossh.link</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>dodossh</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>© DodoTech. MIT licensed.</string>
|
<string>© DodoTech. MIT licensed.</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
+5
-1
@@ -299,7 +299,8 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
|||||||
|
|
||||||
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
|
||||||
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
|
||||||
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore.
|
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore,
|
||||||
|
`17` SpelunkKey.
|
||||||
|
|
||||||
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
|
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
|
||||||
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
|
> `HostCredential` as syncable since the contract was frozen, but this table had no value for
|
||||||
@@ -318,6 +319,9 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
|
|||||||
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
|
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
|
||||||
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
> `AadResourceTypeTests` pins each pairing by name for exactly that reason.
|
||||||
|
|
||||||
|
> **Added 2026-09-29:** `17` SpelunkKey, a Spelunk API key and the address it may be sent to. It is
|
||||||
|
> `14` on the wire, three behind, as `16` ObjectStore is `13`.
|
||||||
|
|
||||||
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
`0` means not applicable and is legal only where the table in §4.2 implies no resource.
|
||||||
|
|
||||||
### 4.4 What this prevents
|
### 4.4 What this prevents
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using DodoSSH.Domain;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Authorization;
|
||||||
|
|
||||||
|
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
|
||||||
|
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
|
||||||
|
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
|
||||||
|
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
|
||||||
|
/// that no endpoint can be reached without it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
|
||||||
|
/// reached by this; it ends on its own idle and lifetime limits.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
|
||||||
|
|
||||||
|
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
|
||||||
|
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
|
||||||
|
: AuthorizationHandler<ActiveAccountRequirement>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context,
|
||||||
|
ActiveAccountRequirement requirement)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
|
||||||
|
// as well would turn that into a 403.
|
||||||
|
if (context.User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||||
|
|
||||||
|
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (user.Status == UserStatus.Active)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
|
|||||||
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
||||||
/// requests from one user would each read the row, each set the timestamp, and the second would
|
/// requests from one user would each read the row, each set the timestamp, and the second would
|
||||||
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
||||||
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
|
/// <para>
|
||||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
/// â—† <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
|
||||||
|
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
|
||||||
|
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
|
||||||
|
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
|
||||||
|
/// against the version from before the update and failed, so enrolling more than an hour after first
|
||||||
|
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.Users
|
var written = await database.Users
|
||||||
.Where(u => u.Id == user.Id
|
.Where(u => u.Id == user.Id
|
||||||
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
||||||
.ExecuteUpdateAsync(
|
.ExecuteUpdateAsync(
|
||||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (written > 0)
|
||||||
|
{
|
||||||
|
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
|
|||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
ArgumentNullException.ThrowIfNull(authorizeResult);
|
ArgumentNullException.ThrowIfNull(authorizeResult);
|
||||||
|
|
||||||
|
var accountNotActive = authorizeResult.Forbidden
|
||||||
|
&& authorizeResult.AuthorizationFailure is { } refusal
|
||||||
|
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
|
||||||
|
|
||||||
|
if (accountNotActive)
|
||||||
|
{
|
||||||
|
await TypedResults.Problem(
|
||||||
|
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden,
|
||||||
|
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
|
||||||
|
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["code"] = ProblemCodes.AccountNotActive,
|
||||||
|
})
|
||||||
|
.ExecuteAsync(context)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var enrollmentMissing = authorizeResult.Forbidden
|
var enrollmentMissing = authorizeResult.Forbidden
|
||||||
&& authorizeResult.AuthorizationFailure is { } failure
|
&& authorizeResult.AuthorizationFailure is { } failure
|
||||||
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
||||||
|
|||||||
@@ -2,9 +2,18 @@
|
|||||||
#
|
#
|
||||||
# docker build -f src/DodoSSH.Api/Dockerfile .
|
# docker build -f src/DodoSSH.Api/Dockerfile .
|
||||||
#
|
#
|
||||||
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at
|
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
|
||||||
# the root and all four are load-bearing here — central package management means a csproj
|
# global.json all sit at the root and all five are load-bearing here — central package
|
||||||
# alone does not name a single version, and a build that cannot see them resolves nothing.
|
# management means a csproj alone does not name a single version, and a build that cannot see
|
||||||
|
# them resolves nothing.
|
||||||
|
#
|
||||||
|
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
|
||||||
|
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
|
||||||
|
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
|
||||||
|
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
|
||||||
|
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
|
||||||
|
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
|
||||||
|
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------------------
|
||||||
# Build
|
# Build
|
||||||
@@ -26,7 +35,7 @@ WORKDIR /src
|
|||||||
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
|
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
|
||||||
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
|
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
|
||||||
# rather than skipping a node.
|
# rather than skipping a node.
|
||||||
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./
|
COPY global.json NuGet.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
|
||||||
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
|
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
|
||||||
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
||||||
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
|
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
|
||||||
|
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
|
||||||
|
/// touch any of the places a removal happens.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void VaultAccessChanged(Guid userId)
|
||||||
|
{
|
||||||
|
hub.VaultAccessChanged(userId);
|
||||||
|
_ = relay.AccessChangedAsync(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
|
|||||||
byte[] fingerprint,
|
byte[] fingerprint,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([user], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using DodoSSH.Api.Authorization;
|
||||||
|
using DodoSSH.Domain.Authorization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
|
||||||
|
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
|
||||||
|
/// their keys and their vault list away at once, and left any shell they had open through the relay running
|
||||||
|
/// into the team's machines until it went idle.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Told by the same announcement that tells their event socket to re-read its vaults — see
|
||||||
|
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
|
||||||
|
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
|
||||||
|
/// has already committed and answered, and a slow re-check must not hold it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RelayAccessWatch(
|
||||||
|
RelaySessionRegistry sessions,
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
ILogger<RelayAccessWatch> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Re-checks every session the user has open, in the background.</summary>
|
||||||
|
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
|
||||||
|
internal Task AccessChangedAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var open = sessions.SessionsOf(userId);
|
||||||
|
|
||||||
|
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scope = scopes.CreateAsyncScope();
|
||||||
|
await using var _ = scope.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
|
||||||
|
|
||||||
|
foreach (var lease in open)
|
||||||
|
{
|
||||||
|
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
|
||||||
|
{
|
||||||
|
lease.Revoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
RelayLog.AccessRecheckFailed(logger, userId, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lease = sessions.TryAdmit(ticket.UserId);
|
var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -113,7 +113,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
|
await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -239,7 +239,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
||||||
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
|
private async Task RelayAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
RelayTicket ticket,
|
||||||
|
CancellationToken revoked,
|
||||||
|
CancellationToken requestAborted)
|
||||||
{
|
{
|
||||||
var started = clock.GetUtcNow();
|
var started = clock.GetUtcNow();
|
||||||
|
|
||||||
@@ -269,7 +273,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
{
|
{
|
||||||
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
||||||
|
|
||||||
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
|
ending = await PumpAsync(socket, target, counters, started, revoked, requestAborted).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
RelayLog.SessionClosed(
|
RelayLog.SessionClosed(
|
||||||
@@ -338,13 +342,14 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
Stream target,
|
Stream target,
|
||||||
Counters counters,
|
Counters counters,
|
||||||
DateTimeOffset started,
|
DateTimeOffset started,
|
||||||
|
CancellationToken revoked,
|
||||||
CancellationToken requestAborted)
|
CancellationToken requestAborted)
|
||||||
{
|
{
|
||||||
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
||||||
|
|
||||||
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
||||||
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
||||||
var minding = MindAsync(counters, started, pump.Token);
|
var minding = MindAsync(counters, started, revoked, pump.Token);
|
||||||
|
|
||||||
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
||||||
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
||||||
@@ -512,7 +517,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
|
private async Task<Ending?> MindAsync(
|
||||||
|
Counters counters,
|
||||||
|
DateTimeOffset started,
|
||||||
|
CancellationToken revoked,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var settings = options.Value;
|
||||||
var hardDeadline = started + settings.MaxSessionDuration;
|
var hardDeadline = started + settings.MaxSessionDuration;
|
||||||
@@ -527,27 +536,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
drainDeadline = now + settings.DrainTimeout;
|
drainDeadline = now + settings.DrainTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now >= hardDeadline)
|
|
||||||
{
|
|
||||||
return new Ending(
|
|
||||||
RelayCloseReason.DurationLimit,
|
|
||||||
RelayProtocol.DurationLimitCloseCode,
|
|
||||||
"This relay session reached its maximum lifetime.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drainDeadline is { } drain && now >= drain)
|
|
||||||
{
|
|
||||||
return ShuttingDown;
|
|
||||||
}
|
|
||||||
|
|
||||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||||
|
|
||||||
if (now >= idleDeadline)
|
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
|
||||||
{
|
{
|
||||||
return new Ending(
|
return ending;
|
||||||
RelayCloseReason.IdleTimeout,
|
|
||||||
RelayProtocol.IdleTimeoutCloseCode,
|
|
||||||
"This relay session carried nothing for the idle timeout.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
||||||
@@ -555,8 +548,8 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
|
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
|
||||||
// than from whenever the idle clock next came round.
|
// than from whenever the idle clock next came round.
|
||||||
using var wake = drainDeadline is null
|
using var wake = drainDeadline is null
|
||||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
|
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
|
||||||
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -564,7 +557,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// The shutdown began. Round again to start the drain.
|
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -575,6 +568,50 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Revocation first, because it is the one reason here that is about the user rather than the session:
|
||||||
|
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
|
||||||
|
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
|
||||||
|
/// </remarks>
|
||||||
|
private static Ending? Due(
|
||||||
|
DateTimeOffset now,
|
||||||
|
bool revoked,
|
||||||
|
DateTimeOffset hardDeadline,
|
||||||
|
DateTimeOffset? drainDeadline,
|
||||||
|
DateTimeOffset idleDeadline)
|
||||||
|
{
|
||||||
|
if (revoked)
|
||||||
|
{
|
||||||
|
return AccessRevoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= hardDeadline)
|
||||||
|
{
|
||||||
|
return new Ending(
|
||||||
|
RelayCloseReason.DurationLimit,
|
||||||
|
RelayProtocol.DurationLimitCloseCode,
|
||||||
|
"This relay session reached its maximum lifetime.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drainDeadline is { } drain && now >= drain)
|
||||||
|
{
|
||||||
|
return ShuttingDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return now >= idleDeadline
|
||||||
|
? new Ending(
|
||||||
|
RelayCloseReason.IdleTimeout,
|
||||||
|
RelayProtocol.IdleTimeoutCloseCode,
|
||||||
|
"This relay session carried nothing for the idle timeout.")
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ending AccessRevoked { get; } = new(
|
||||||
|
RelayCloseReason.AccessRevoked,
|
||||||
|
RelayProtocol.AccessRevokedCloseCode,
|
||||||
|
"You no longer have access to this host.");
|
||||||
|
|
||||||
private static Ending ClientGone { get; } =
|
private static Ending ClientGone { get; } =
|
||||||
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||||
|
|
||||||
|
|||||||
@@ -91,4 +91,14 @@ internal static partial class RelayLog
|
|||||||
Level = LogLevel.Warning,
|
Level = LogLevel.Warning,
|
||||||
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
||||||
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
|
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
|
||||||
|
/// existed, and each still ends at its idle timeout or its lifetime.
|
||||||
|
/// </remarks>
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2309,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
|
||||||
|
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, Exception exception);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
{
|
{
|
||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
private readonly Dictionary<Guid, int> perUser = [];
|
private readonly Dictionary<Guid, int> perUser = [];
|
||||||
|
private readonly HashSet<Lease> open = [];
|
||||||
private int total;
|
private int total;
|
||||||
|
|
||||||
/// <summary>How many sessions are open on this node.</summary>
|
/// <summary>How many sessions are open on this node.</summary>
|
||||||
@@ -55,7 +56,16 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
|
|
||||||
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
||||||
/// <returns>A lease that gives the place back when disposed.</returns>
|
/// <returns>A lease that gives the place back when disposed.</returns>
|
||||||
internal Lease? TryAdmit(Guid userId)
|
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
|
||||||
|
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return [.. open.Where(lease => lease.UserId == userId)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Lease? TryAdmit(Guid userId, Guid vaultId)
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var settings = options.Value;
|
||||||
|
|
||||||
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
perUser[userId] = held + 1;
|
perUser[userId] = held + 1;
|
||||||
total++;
|
total++;
|
||||||
|
|
||||||
return new Lease(this, userId);
|
var lease = new Lease(this, userId, vaultId);
|
||||||
|
open.Add(lease);
|
||||||
|
|
||||||
|
return lease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Release(Guid userId)
|
private void Release(Lease lease, Guid userId)
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
|
open.Remove(lease);
|
||||||
total--;
|
total--;
|
||||||
|
|
||||||
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
||||||
@@ -95,16 +109,41 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
||||||
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
|
internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly CancellationTokenSource revoked = new();
|
||||||
|
|
||||||
private int released;
|
private int released;
|
||||||
|
|
||||||
|
/// <summary>Whose session this is.</summary>
|
||||||
|
internal Guid UserId { get; } = userId;
|
||||||
|
|
||||||
|
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
|
||||||
|
internal Guid VaultId { get; } = vaultId;
|
||||||
|
|
||||||
|
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
|
||||||
|
internal CancellationToken Revoked => revoked.Token;
|
||||||
|
|
||||||
|
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
|
||||||
|
internal void Revoke()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
revoked.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Already over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref released, 1) == 0)
|
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||||
{
|
{
|
||||||
registry.Release(userId);
|
registry.Release(this, UserId);
|
||||||
|
revoked.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ internal static class ItemKinds
|
|||||||
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
|
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
|
||||||
new HostGroupKind(), new TagKind(), new SnippetKind(),
|
new HostGroupKind(), new TagKind(), new SnippetKind(),
|
||||||
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
|
||||||
|
new SpelunkKeyKind(),
|
||||||
}.ToDictionary(kind => kind.WireType);
|
}.ToDictionary(kind => kind.WireType);
|
||||||
|
|
||||||
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
|
||||||
@@ -1093,3 +1094,98 @@ internal sealed class ObjectStoreKind : IItemKind
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="ObjectStoreKind"/>'s rules, because it holds the same class of thing: an API key is a password,
|
||||||
|
/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SpelunkKeyKind : IItemKind
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<IVaultItem?> FindAsync(
|
||||||
|
DodoDbContext database,
|
||||||
|
Guid id,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
await database.SpelunkKeys.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.SpelunkKeys
|
||||||
|
.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 key = new VaultSpelunkKey { Id = id, VaultId = vaultId };
|
||||||
|
|
||||||
|
database.SpelunkKeys.Add(key);
|
||||||
|
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Refuses every plaintext field there is.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The relay fields are refused although this type holds an address, as they are for a bucket: the
|
||||||
|
/// address belongs in the ciphertext, and the server has no business keeping a list of where its users
|
||||||
|
/// manage their servers from.
|
||||||
|
/// </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 Spelunk key is not something the server dials; its address stays encrypted.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
|
||||||
|
{
|
||||||
|
error = "A Spelunk key's contents are inside its payload.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
|
||||||
|
{
|
||||||
|
error = "A Spelunk key 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;
|
||||||
|
}
|
||||||
@@ -100,9 +100,7 @@ internal sealed class SyncService(
|
|||||||
{
|
{
|
||||||
ValidateBatchLimits(request);
|
ValidateBatchLimits(request);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([vault], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -536,9 +534,21 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
||||||
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
||||||
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// â—† <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
|
||||||
|
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
|
||||||
|
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
|
||||||
|
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
|
||||||
|
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
|
||||||
|
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
|
||||||
|
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
|
||||||
|
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
@@ -571,10 +581,13 @@ internal sealed class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. changes.Select(change => Describe(
|
return [.. changes
|
||||||
change,
|
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|
||||||
kinds.GetValueOrDefault(change.EntityType),
|
|| row.ChangeSequence <= change.Sequence)
|
||||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
.Select(change => Describe(
|
||||||
|
change,
|
||||||
|
kinds.GetValueOrDefault(change.EntityType),
|
||||||
|
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
||||||
|
|||||||
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
|
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
|
||||||
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
|
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
|
||||||
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
|
// checks it again, because deleting a team's last vault archives it too.
|
||||||
if (!access.IsOwner)
|
if (!access.IsOwner)
|
||||||
{
|
{
|
||||||
return Problems.Coded(
|
return Problems.Coded(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Teams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises the writes that decide what a team owns, for the life of the transaction.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
|
||||||
|
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
|
||||||
|
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
|
||||||
|
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
|
||||||
|
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TeamLock
|
||||||
|
{
|
||||||
|
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
|
||||||
|
database.Database.ExecuteSqlAsync(
|
||||||
|
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -244,28 +245,37 @@ internal sealed class TeamService(
|
|||||||
Team team,
|
Team team,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actor);
|
||||||
ArgumentNullException.ThrowIfNull(team);
|
ArgumentNullException.ThrowIfNull(team);
|
||||||
|
|
||||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
// Both callers check this first and answer the caller properly; this is what stops a third one that
|
||||||
|
// forgets from letting an admin archive the team out from under its owner.
|
||||||
if (vaultCount > 0)
|
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
|
||||||
{
|
{
|
||||||
throw new TeamNotEmptyException(
|
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||||
string.Create(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
var archived = await database.ExecuteFreshAsync([team], async () =>
|
||||||
|
|
||||||
var archived = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await using var _ = transaction.ConfigureAwait(false);
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
|
||||||
|
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (vaultCount > 0)
|
||||||
|
{
|
||||||
|
throw new TeamNotEmptyException(
|
||||||
|
string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
||||||
|
}
|
||||||
|
|
||||||
var memberships = await database.TeamMemberships
|
var memberships = await database.TeamMemberships
|
||||||
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
@@ -335,9 +345,7 @@ internal sealed class TeamService(
|
|||||||
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
|
||||||
|
|
||||||
await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -716,9 +724,7 @@ internal sealed class TeamService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
var revoked = await database.ExecuteFreshAsync([membership], async () =>
|
||||||
|
|
||||||
var revoked = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
|
|||||||
{
|
{
|
||||||
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
if (orphaned is not null)
|
// Archived only by its owner. Archiving revokes every membership, the owner's included, and an
|
||||||
|
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
|
||||||
|
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
|
||||||
|
// the owner, who can archive it or make a vault in it.
|
||||||
|
if (orphaned is not null
|
||||||
|
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
|
||||||
{
|
{
|
||||||
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
|
|||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
|
||||||
{
|
|
||||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
|
||||||
// the same id, which must not surface as a 500 about a constraint.
|
|
||||||
throw new TeamInvalidException(
|
|
||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
|
||||||
}
|
|
||||||
|
|
||||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||||
|
|
||||||
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
|
|||||||
RekeyRequired: false);
|
RekeyRequired: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
|
||||||
|
private Task WriteVaultAsync(
|
||||||
|
UserAccount user,
|
||||||
|
Team team,
|
||||||
|
CreateTeamVaultRequest request,
|
||||||
|
string name,
|
||||||
|
UserKey key,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
database.ExecuteFreshAsync([team], async () =>
|
||||||
|
{
|
||||||
|
var transaction = await database.Database
|
||||||
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
|
||||||
|
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||||
|
{
|
||||||
|
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||||
|
// the same id, which must not surface as a 500 about a constraint.
|
||||||
|
throw new TeamInvalidException(
|
||||||
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
|
||||||
|
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var live = await database.Teams
|
||||||
|
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!live)
|
||||||
|
{
|
||||||
|
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||||
private void AddVaultWithSelfGrant(
|
private void AddVaultWithSelfGrant(
|
||||||
UserAccount user,
|
UserAccount user,
|
||||||
|
|||||||
@@ -41,13 +41,12 @@ builder.Services.AddScoped<VaultGrantService>();
|
|||||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||||
|
|
||||||
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
|
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
|
||||||
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
|
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
|
||||||
// write paths that announce a change need only the two methods that announce one, and should not gain
|
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
|
||||||
// a reference to connection management to get them.
|
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
|
||||||
builder.Services.AddSingleton<VaultEventHub>();
|
builder.Services.AddSingleton<VaultEventHub>();
|
||||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
|
||||||
provider => provider.GetRequiredService<VaultEventHub>());
|
|
||||||
|
|
||||||
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
||||||
builder.Services.AddDodoRelay();
|
builder.Services.AddDodoRelay();
|
||||||
@@ -55,6 +54,7 @@ builder.Services.AddDodoRelay();
|
|||||||
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
||||||
// DbContext, and a singleton would capture one for the lifetime of the process.
|
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||||
|
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
|
||||||
|
|
||||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ internal static class Auth
|
|||||||
|
|
||||||
private static void AddDodoPolicies(AuthorizationOptions options)
|
private static void AddDodoPolicies(AuthorizationOptions options)
|
||||||
{
|
{
|
||||||
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
// Both policies also require the account to be active. A suspended or deprovisioned account still
|
||||||
|
// holds a token the identity provider will vouch for until it expires — the status is this server's
|
||||||
|
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
|
||||||
|
options.AddPolicy(
|
||||||
|
AuthenticatedPolicy,
|
||||||
|
policy => policy
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.AddRequirements(new Authorization.ActiveAccountRequirement()));
|
||||||
|
|
||||||
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
||||||
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
||||||
@@ -72,7 +79,7 @@ internal static class Auth
|
|||||||
EnrolledPolicy,
|
EnrolledPolicy,
|
||||||
policy => policy
|
policy => policy
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.AddRequirements(new Authorization.EnrolledRequirement()));
|
.AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
|
||||||
|
|
||||||
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
|
||||||
|
/// rather than from what the failed attempt left in the change tracker.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
|
||||||
|
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
|
||||||
|
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
|
||||||
|
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
|
||||||
|
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
|
||||||
|
/// its two role changes, each reporting success.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
|
||||||
|
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
|
||||||
|
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
|
||||||
|
/// before: nothing is cleared or reloaded until something has failed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class FreshAttempts
|
||||||
|
{
|
||||||
|
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
|
||||||
|
/// <param name="database">The request's context.</param>
|
||||||
|
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
|
||||||
|
/// <param name="attempt">The transaction, which begins and commits its own.</param>
|
||||||
|
internal static Task<T> ExecuteFreshAsync<T>(
|
||||||
|
this DodoDbContext database,
|
||||||
|
IReadOnlyList<object> carried,
|
||||||
|
Func<Task<T>> attempt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(database);
|
||||||
|
ArgumentNullException.ThrowIfNull(carried);
|
||||||
|
ArgumentNullException.ThrowIfNull(attempt);
|
||||||
|
|
||||||
|
var strategy = database.Database.CreateExecutionStrategy();
|
||||||
|
var attempts = 0;
|
||||||
|
|
||||||
|
return strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
if (attempts++ > 0)
|
||||||
|
{
|
||||||
|
database.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
foreach (var entity in carried)
|
||||||
|
{
|
||||||
|
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attempt().ConfigureAwait(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
|
||||||
|
internal static Task ExecuteFreshAsync(
|
||||||
|
this DodoDbContext database,
|
||||||
|
IReadOnlyList<object> carried,
|
||||||
|
Func<Task> attempt) =>
|
||||||
|
database.ExecuteFreshAsync(
|
||||||
|
carried,
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
await attempt().ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -29,14 +30,18 @@ internal static class Persistence
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
||||||
|
|
||||||
services.AddDbContext<DodoDbContext>(options => options
|
services.AddDbContext<DodoDbContext>((provider, options) => options
|
||||||
.UseNpgsql(connectionString, npgsql => npgsql
|
.UseNpgsql(connectionString, npgsql => npgsql
|
||||||
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
||||||
// Retry on transient faults only. Note this is safe here because writes go
|
// Retry on transient faults only. A write transaction replayed by the strategy runs on
|
||||||
// through explicitly-managed transactions rather than relying on the execution
|
// the same context, which still holds what the failed attempt tracked — every such
|
||||||
// strategy to replay ambient ones.
|
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
|
||||||
|
// the database instead.
|
||||||
.EnableRetryOnFailure(3))
|
.EnableRetryOnFailure(3))
|
||||||
.UseSnakeCaseNamingConvention());
|
.UseSnakeCaseNamingConvention()
|
||||||
|
// None in production. The seam a test uses to make a commit fail the way a dropped
|
||||||
|
// connection would, which is the only way to watch a retry happen.
|
||||||
|
.AddInterceptors(provider.GetServices<IInterceptor>()));
|
||||||
|
|
||||||
services.AddHealthChecks()
|
services.AddHealthChecks()
|
||||||
.AddCheck<DatabaseHealthCheck>(
|
.AddCheck<DatabaseHealthCheck>(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal static class RelaySetup
|
|||||||
// the network seam holds no state.
|
// the network seam holds no state.
|
||||||
services.AddSingleton<RelayTicketCodec>();
|
services.AddSingleton<RelayTicketCodec>();
|
||||||
services.AddSingleton<RelaySessionRegistry>();
|
services.AddSingleton<RelaySessionRegistry>();
|
||||||
|
services.AddSingleton<RelayAccessWatch>();
|
||||||
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
||||||
|
|
||||||
// The application name isolates this deployment's payloads from any other ASP.NET application
|
// The application name isolates this deployment's payloads from any other ASP.NET application
|
||||||
|
|||||||
@@ -1002,7 +1002,8 @@
|
|||||||
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
||||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
||||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
PlaceholderText="{Binding EditorUsernamePlaceholder}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
||||||
@@ -1016,6 +1017,7 @@
|
|||||||
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}"
|
||||||
HorizontalAlignment="Stretch" MinHeight="44">
|
HorizontalAlignment="Stretch" MinHeight="44">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
@@ -1028,6 +1030,9 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
<!-- Why the login is greyed out, as on the desktop. See VaultViewModel.EditorAsksForLogin. -->
|
||||||
|
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !EditorAsksForLogin}"
|
||||||
|
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||||
@@ -1212,6 +1217,41 @@
|
|||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
|
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
|
||||||
|
one unticks the other. The key is a keychain credential chosen here, never typed here. See
|
||||||
|
VaultViewModel.Spelunk.cs.
|
||||||
|
-->
|
||||||
|
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5"
|
||||||
|
Text="Reach this host through Spelunk"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding EditorThroughSpelunk}">
|
||||||
|
<TextBox Classes="field" Text="{Binding EditorSpelunkApi}"
|
||||||
|
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||||
|
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
|
||||||
|
PlaceholderText="Spelunk server id" />
|
||||||
|
<TextBlock Classes="detail" Text="Spelunk API key" />
|
||||||
|
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||||
|
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||||
|
HorizontalAlignment="Stretch" MinHeight="44">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
||||||
|
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5"
|
||||||
|
Text="Sign in through Spelunk (no SSH key needed)"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Classes="body"
|
||||||
|
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Choose a Spelunk API key (Admin role) saved from the desktop's Settings → Security, or a credential whose password is the key; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||||
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ namespace DodoSSH.Client.App;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class DodoSshApp : Application
|
internal sealed partial class DodoSshApp : Application
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
|
||||||
|
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
|
||||||
|
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
|
||||||
|
/// the UI thread only.
|
||||||
|
/// </remarks>
|
||||||
|
private static Action<SpelunkLink>? openLink;
|
||||||
|
|
||||||
|
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
|
||||||
|
private static SpelunkLink? waitingLink;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
@@ -34,12 +48,63 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
// Recorded, not handled: see CrashLog for why the process still goes.
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
|
||||||
|
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
|
||||||
|
|
||||||
|
ListenForOpenedLinks();
|
||||||
ShowSomethingWhileComposing(desktop);
|
ShowSomethingWhileComposing(desktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the links the platform hands a running application, which on macOS is every link.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
|
||||||
|
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
|
||||||
|
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
|
||||||
|
/// activation. The scheme is declared for it in build/macos's Info.plist template.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
|
||||||
|
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
|
||||||
|
/// desktops that deliver links as arguments instead.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void ListenForOpenedLinks()
|
||||||
|
{
|
||||||
|
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activatable.Activated += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
|
||||||
|
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
|
||||||
|
{
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
|
||||||
|
private static void Receive(SpelunkLink link)
|
||||||
|
{
|
||||||
|
if (openLink is { } open)
|
||||||
|
{
|
||||||
|
open(link);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
waitingLink = link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts a window on screen first, and composes the application behind it.
|
/// Puts a window on screen first, and composes the application behind it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -206,9 +271,20 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||||
|
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
|
||||||
|
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
|
||||||
var others = SingleInstance.Listen(
|
var others = SingleInstance.Listen(
|
||||||
paths,
|
paths,
|
||||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
|
||||||
|
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
|
||||||
|
|
||||||
|
openLink = viewModel.OpenLink;
|
||||||
|
|
||||||
|
if ((waitingLink ?? Program.LaunchLink) is { } first)
|
||||||
|
{
|
||||||
|
waitingLink = null;
|
||||||
|
viewModel.OpenLink(first);
|
||||||
|
}
|
||||||
|
|
||||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||||
@@ -280,39 +356,9 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
copyToClipboard: ClipboardWriter(desktop),
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
readClipboard: ClipboardReader(desktop),
|
readClipboard: ClipboardReader(desktop),
|
||||||
updates: updates,
|
updates: updates,
|
||||||
openInEditor: OpenInDefaultEditorAsync,
|
openInEditor: LocalEditor.OpenAsync,
|
||||||
watchFile: WatchFile);
|
watchFile: WatchFile);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hands a local file to whatever this machine opens that kind of file with.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
|
|
||||||
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
|
|
||||||
/// Services binding depending on which desktop this is, and watching a file is a platform object with
|
|
||||||
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
|
|
||||||
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
|
|
||||||
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
|
|
||||||
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
|
||||||
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
|
|
||||||
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private static Task OpenInDefaultEditorAsync(string path)
|
|
||||||
{
|
|
||||||
using var opened = System.Diagnostics.Process.Start(
|
|
||||||
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Watches one file for saves, until the handle is disposed.
|
/// Watches one file for saves, until the handle is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes down why the application died, in the profile, before it does.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// An exception nothing caught used to close the window and leave nothing behind — no file, no event, no
|
||||||
|
/// sentence — so the only report anybody could make was "it closed". This does not keep the process alive:
|
||||||
|
/// an exception that reached the top is one no code was written to recover from, and carrying on in whatever
|
||||||
|
/// state it left would be a guess. What it adds is the record.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Bounded, and appended to rather than rewritten, so a run of crashes keeps its history without the file
|
||||||
|
/// growing for ever. Every write is best-effort: a profile that cannot be written to is a reason to lose the
|
||||||
|
/// log, never a reason to fail harder.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class CrashLog
|
||||||
|
{
|
||||||
|
/// <summary>Past this, the next entry starts a fresh file.</summary>
|
||||||
|
private const long MaxBytes = 1024 * 1024;
|
||||||
|
|
||||||
|
private static readonly Lock Gate = new();
|
||||||
|
|
||||||
|
private static string? file;
|
||||||
|
|
||||||
|
/// <summary>Starts recording, for the process's two last-chance hooks.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The UI thread's own hook is added by the application once Avalonia is running — see
|
||||||
|
/// <see cref="RecordFromTheUiThread"/> — because the dispatcher must not be touched before the platform
|
||||||
|
/// has set it up.
|
||||||
|
/// </remarks>
|
||||||
|
internal static void Install(ClientPaths paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
file = paths.CrashLogFile;
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, e) => Write("Unhandled", e.ExceptionObject as Exception);
|
||||||
|
|
||||||
|
// A fire-and-forget task that faulted. It does not end the process, which is exactly why it needs
|
||||||
|
// writing down: the work it was doing simply never happened, and nothing else would ever say so.
|
||||||
|
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||||
|
{
|
||||||
|
Write("Unobserved task", e.Exception);
|
||||||
|
e.SetObserved();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The UI thread's hook: records the exception and lets it take the process as before.</summary>
|
||||||
|
internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
|
||||||
|
|
||||||
|
/// <summary>Appends one entry; never throws.</summary>
|
||||||
|
internal static void Write(string where, Exception? exception)
|
||||||
|
{
|
||||||
|
if (file is not { } path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = $"{TimeProvider.System.GetUtcNow():O} {where}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
|
||||||
|
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
|
||||||
|
if (File.Exists(path) && new FileInfo(path).Length > MaxBytes)
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.AppendAllText(path, entry);
|
||||||
|
}
|
||||||
|
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Best-effort, by design: see the type's remarks.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a platform key store calls the hardware key behind one profile's device unlock.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// â—† <b>One key per profile, because the key outlives the file and is shared by name.</b> The CNG key and
|
||||||
|
/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
|
||||||
|
/// profile directory. While every profile used the same name, the release build and the nightly — which
|
||||||
|
/// <see cref="ClientPaths.ForChannel"/> keeps apart precisely so neither can damage the other — shared one
|
||||||
|
/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The default profile keeps the name it has always had</b>, so an existing install's sealed key still
|
||||||
|
/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
|
||||||
|
/// than the directory's own name, because a portable copy can live in any folder and two such folders can
|
||||||
|
/// end in the same name.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class DeviceKeyNames
|
||||||
|
{
|
||||||
|
/// <summary>The name of the key behind <paramref name="paths"/>' device unlock.</summary>
|
||||||
|
/// <param name="paths">The profile.</param>
|
||||||
|
/// <param name="legacy">The name every profile used before names were per profile.</param>
|
||||||
|
internal static string For(ClientPaths paths, string legacy)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
var directory = Normalise(paths.DataDirectory);
|
||||||
|
|
||||||
|
if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
|
||||||
|
|
||||||
|
return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalise(string directory) =>
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a local copy of a remote file for editing, without running it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
|
||||||
|
/// shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch Services binding
|
||||||
|
/// depending on which desktop this is. The shell decides <em>when</em>; see
|
||||||
|
/// <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// â—† <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
|
||||||
|
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
|
||||||
|
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
|
||||||
|
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
|
||||||
|
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
|
||||||
|
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
|
||||||
|
/// which is the default text editor whatever the type. Everything else still goes to its association, which
|
||||||
|
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
|
||||||
|
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
|
||||||
|
/// by every mainstream desktop's file associations in an editor, not run.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||||
|
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
|
||||||
|
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class LocalEditor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
|
||||||
|
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
|
||||||
|
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
|
||||||
|
/// items, and the script hosts that installers do not register there.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly string[] WindowsRunsThese =
|
||||||
|
[
|
||||||
|
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
|
||||||
|
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
|
||||||
|
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
|
||||||
|
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
|
||||||
|
private static readonly string[] MacRunsThese =
|
||||||
|
[
|
||||||
|
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
|
||||||
|
".tool", ".webloc", ".workflow",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Opens <paramref name="path"/> for editing.</summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// This desktop would run the file and has no editor to offer in its place.
|
||||||
|
/// </exception>
|
||||||
|
internal static Task OpenAsync(string path)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
|
||||||
|
using var opened = Process.Start(StartInfoFor(path));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
|
||||||
|
internal static ProcessStartInfo StartInfoFor(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
|
||||||
|
{
|
||||||
|
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
|
||||||
|
|
||||||
|
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
|
||||||
|
{
|
||||||
|
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
|
||||||
|
+ "Download it and open it in an editor instead.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProcessStartInfo(path) { UseShellExecute = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether Windows' default action for this file is to run it.</summary>
|
||||||
|
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
|
||||||
|
{
|
||||||
|
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
|
||||||
|
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
|
||||||
|
/// <c>run.bat</c> by the time anything acts on it.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool HasExtension(string path, IEnumerable<string> extensions)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
|
||||||
|
|
||||||
|
return extension.Length > 0
|
||||||
|
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
|
|
||||||
private readonly ClientPaths paths;
|
private readonly ClientPaths paths;
|
||||||
|
|
||||||
|
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="KeyTag"/> for the default profile and a tag of its own for every other, so that signing
|
||||||
|
/// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
|
||||||
|
/// registered before this is still sealed to the shared tag and is read from there until it registers
|
||||||
|
/// again; it is never deleted from a profile that does not own it.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly string keyTag;
|
||||||
|
|
||||||
/// <summary>Creates the store.</summary>
|
/// <summary>Creates the store.</summary>
|
||||||
public MacDeviceKeyStore(ClientPaths paths)
|
public MacDeviceKeyStore(ClientPaths paths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
|
keyTag = DeviceKeyNames.For(paths, KeyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
ReadOnlyMemory<byte> devicePrivateKey,
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sealedKey = Seal(devicePrivateKey.Span)
|
var sealedKey = Seal(devicePrivateKey.Span, keyTag)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||||
|
|
||||||
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return Unseal(sealedKey);
|
// The shared tag only while this profile has no key of its own, so one unlock is one prompt.
|
||||||
|
return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
||||||
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
||||||
// left in the enclave with no ciphertext to open is inert.
|
// left in the enclave with no ciphertext to open is inert.
|
||||||
_ = DeleteKey(symbols, KeyTag);
|
_ = DeleteKey(symbols, keyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
||||||
/// them is the same — do not offer a device unlock.
|
/// them is the same — do not offer a device unlock.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
|
/// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
|
||||||
|
private static bool HasKey(string tag)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey, string tag)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
|
|
||||||
// Created on first use rather than at registration, so that a device key re-registered after
|
// Created on first use rather than at registration, so that a device key re-registered after
|
||||||
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
||||||
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
|
var privateKey = FindKey(scope, symbols, tag, prompt: null);
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
if (privateKey == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
privateKey = CreateEnclaveKey(scope, symbols, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
if (privateKey == IntPtr.Zero)
|
||||||
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// on a thread that is also expected to draw the window behind it.
|
/// on a thread that is also expected to draw the window behind it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Unseal(byte[] sealedKey)
|
private static byte[]? Unseal(byte[] sealedKey, string tag)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
if (privateKey == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The device key store this machine supports, chosen once and off the UI thread.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Choosing a store means probing for it, and on Windows the probe is creating an RSA-2048 key inside the TPM
|
||||||
|
/// and deleting it again — see <see cref="WindowsDeviceKeyStore.IsSupported"/> — which is a slow device doing
|
||||||
|
/// real work. It used to run synchronously on the UI thread three times over: once to choose the store at
|
||||||
|
/// startup, again when the unlock card asked whether to offer the gesture, and again after every unlock when
|
||||||
|
/// the security page asked whether to offer registering. A machine does not gain or lose a TPM while this
|
||||||
|
/// process runs, so the answer is worked out once, on the thread pool, and every question after it is a
|
||||||
|
/// lookup.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Available means the choice landed on a real store, which is what the probe that chose it established.
|
||||||
|
/// Everything else waits for the choice and hands over to it — in practice it has long since finished,
|
||||||
|
/// since nothing reaches for the device key until somebody is at the unlock card.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
|
||||||
|
{
|
||||||
|
private readonly Task<IDeviceKeyStore> chosen;
|
||||||
|
|
||||||
|
/// <summary>Starts choosing at once, on the thread pool.</summary>
|
||||||
|
internal ProbedDeviceKeyStore(Func<IDeviceKeyStore> choose)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(choose);
|
||||||
|
chosen = Task.Run(choose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The store the probe settled on, for a test that needs to know which.</summary>
|
||||||
|
internal Task<IDeviceKeyStore> Chosen => chosen;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||||
|
await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,12 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||||
/// what makes the exclusion invisible rather than mysterious.
|
/// what makes the exclusion invisible rather than mysterious.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
|
||||||
|
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
|
||||||
|
/// second launch like any other — so the doorbell has a second message, which carries the link to the
|
||||||
|
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class SingleInstance
|
internal static class SingleInstance
|
||||||
{
|
{
|
||||||
@@ -46,6 +52,30 @@ internal static class SingleInstance
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string ComeForward = "dodossh.come-forward";
|
private const string ComeForward = "dodossh.come-forward";
|
||||||
|
|
||||||
|
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
|
||||||
|
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
|
||||||
|
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
|
||||||
|
/// passes is ignored whole — the window does not even come forward for it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
|
||||||
|
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
|
||||||
|
/// than half-obeying it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private const string OpenLinkPrefix = "dodossh.open-link ";
|
||||||
|
|
||||||
|
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
|
||||||
|
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
|
||||||
|
/// process spends on it.
|
||||||
|
/// </remarks>
|
||||||
|
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
|
||||||
|
|
||||||
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||||
@@ -86,7 +116,11 @@ internal static class SingleInstance
|
|||||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||||
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||||
/// </param>
|
/// </param>
|
||||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
/// <param name="link">
|
||||||
|
/// The link this launch was started with, if any — passed to the running instance instead of a bare
|
||||||
|
/// request to come forward.
|
||||||
|
/// </param>
|
||||||
|
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
@@ -116,7 +150,7 @@ internal static class SingleInstance
|
|||||||
|
|
||||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||||
// round trip on a local pipe rather than the whole window below.
|
// round trip on a local pipe rather than the whole window below.
|
||||||
if (AskTheRunningOneToComeForward(paths))
|
if (AskTheRunningOneToComeForward(paths, link))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -142,25 +176,32 @@ internal static class SingleInstance
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="paths">The profile this instance claimed.</param>
|
/// <param name="paths">The profile this instance claimed.</param>
|
||||||
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||||
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
/// <param name="openLink">
|
||||||
|
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
|
||||||
|
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
|
||||||
|
/// </param>
|
||||||
|
internal static IAsyncDisposable Listen(
|
||||||
|
ClientPaths paths,
|
||||||
|
Action comeForward,
|
||||||
|
Action<SpelunkLink>? openLink = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentNullException.ThrowIfNull(comeForward);
|
ArgumentNullException.ThrowIfNull(comeForward);
|
||||||
|
|
||||||
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
|
||||||
|
|
||||||
listener.Start();
|
listener.Start();
|
||||||
|
|
||||||
return listener;
|
return listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
/// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
|
||||||
/// <returns>Whether anything was listening.</returns>
|
/// <returns>Whether anything was listening.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||||
/// and proves itself, and this is the part with a protocol in it.
|
/// and proves itself, and this is the part with a protocol in it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
@@ -175,7 +216,9 @@ internal static class SingleInstance
|
|||||||
|
|
||||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||||
|
|
||||||
writer.WriteLine(ComeForward);
|
// The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
|
||||||
|
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
|
||||||
|
writer.WriteLine(Message(link));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -188,6 +231,42 @@ internal static class SingleInstance
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
|
||||||
|
internal static string Message(SpelunkLink? link) =>
|
||||||
|
link is null ? ComeForward : OpenLinkPrefix + link;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
|
||||||
|
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
|
||||||
|
/// including a link that is almost right — the caller then does nothing at all.
|
||||||
|
/// </returns>
|
||||||
|
internal static bool TryRead(string? said, out SpelunkLink? link)
|
||||||
|
{
|
||||||
|
link = null;
|
||||||
|
|
||||||
|
if (said is null || said.Length > MaxMessageLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
|
||||||
|
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
|
||||||
|
{
|
||||||
|
link = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||||
@@ -278,14 +357,16 @@ internal static class SingleInstance
|
|||||||
{
|
{
|
||||||
private readonly string name;
|
private readonly string name;
|
||||||
private readonly Action comeForward;
|
private readonly Action comeForward;
|
||||||
|
private readonly Action<SpelunkLink>? openLink;
|
||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
private Task? loop;
|
private Task? loop;
|
||||||
|
|
||||||
internal Doorbell(string name, Action comeForward)
|
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
|
||||||
{
|
{
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.comeForward = comeForward;
|
this.comeForward = comeForward;
|
||||||
|
this.openLink = openLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||||
@@ -324,11 +405,16 @@ internal static class SingleInstance
|
|||||||
// leaveOpen, because this server outlives the conversation. See the remark above.
|
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||||
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||||
{
|
{
|
||||||
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
if (TryRead(said, out var link))
|
||||||
{
|
{
|
||||||
comeForward();
|
comeForward();
|
||||||
|
|
||||||
|
if (link is not null)
|
||||||
|
{
|
||||||
|
openLink?.Invoke(link);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +434,39 @@ internal static class SingleInstance
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
|
||||||
|
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
|
||||||
|
/// reader buffers underneath, and a real message is one short line.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<string?> ReadBoundedLineAsync(
|
||||||
|
StreamReader reader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var line = new StringBuilder();
|
||||||
|
var next = new char[1];
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
|
||||||
|
{
|
||||||
|
if (next[0] == '\n')
|
||||||
|
{
|
||||||
|
return line.ToString().TrimEnd('\r');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Length == MaxMessageLength)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.Append(next[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||||
|
|||||||
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class DesktopDeviceKeyStores
|
public static class DesktopDeviceKeyStores
|
||||||
{
|
{
|
||||||
/// <summary>The best store this machine supports.</summary>
|
/// <summary>The best store this machine supports, chosen on the thread pool.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
|
||||||
|
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
|
||||||
|
/// thread at startup.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="paths">Where a stored key lives.</param>
|
/// <param name="paths">Where a stored key lives.</param>
|
||||||
/// <param name="askForPin">
|
/// <param name="askForPin">
|
||||||
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
||||||
@@ -57,6 +62,13 @@ public static class DesktopDeviceKeyStores
|
|||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentNullException.ThrowIfNull(askForPin);
|
ArgumentNullException.ThrowIfNull(askForPin);
|
||||||
|
|
||||||
|
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDeviceKeyStore Choose(
|
||||||
|
ClientPaths paths,
|
||||||
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||||
|
{
|
||||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||||
{
|
{
|
||||||
return new WindowsDeviceKeyStore(paths);
|
return new WindowsDeviceKeyStore(paths);
|
||||||
@@ -123,6 +135,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
|
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
|
||||||
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
||||||
/// their gesture had stopped working.
|
/// their gesture had stopped working.
|
||||||
|
/// <para>
|
||||||
|
/// This is the default profile's key, and the name every profile shared before keys were per profile;
|
||||||
|
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
||||||
|
|
||||||
@@ -133,11 +149,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
|
|
||||||
private readonly ClientPaths paths;
|
private readonly ClientPaths paths;
|
||||||
|
|
||||||
|
/// <summary>This profile's own key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
|
||||||
|
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
|
||||||
|
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
|
||||||
|
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
|
||||||
|
/// profile that does not own it, which is the half of this that matters.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly string keyName;
|
||||||
|
|
||||||
/// <summary>Creates the store.</summary>
|
/// <summary>Creates the store.</summary>
|
||||||
public WindowsDeviceKeyStore(ClientPaths paths)
|
public WindowsDeviceKeyStore(ClientPaths paths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
|
keyName = DeviceKeyNames.For(paths, KeyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -182,7 +209,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
ReadOnlyMemory<byte> devicePrivateKey,
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var key = OpenOrCreate();
|
using var key = OpenOrCreate(keyName);
|
||||||
using var rsa = new RSACng(key);
|
using var rsa = new RSACng(key);
|
||||||
|
|
||||||
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
||||||
@@ -204,7 +231,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return Unseal(sealedKey);
|
// The shared key only while this profile has none of its own: a profile registered before keys were
|
||||||
|
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
|
||||||
|
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
|
||||||
|
? KeyName
|
||||||
|
: keyName;
|
||||||
|
|
||||||
|
return Unseal(sealedKey, sealedTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -215,9 +248,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
File.Delete(paths.DeviceKeyFile);
|
File.Delete(paths.DeviceKeyFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
// This profile's key and never the shared one, unless this is the profile that owns it. Another
|
||||||
|
// profile's device unlock may still be sealed to it.
|
||||||
|
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
|
||||||
{
|
{
|
||||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
|
||||||
key.Delete();
|
key.Delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,16 +266,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
||||||
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Unseal(byte[] sealedKey)
|
private static byte[]? Unseal(byte[] sealedKey, string name)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
|
||||||
using var rsa = new RSACng(key);
|
using var rsa = new RSACng(key);
|
||||||
|
|
||||||
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
||||||
@@ -256,18 +291,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
||||||
/// silently is the one behaviour this whole file exists to prevent.
|
/// silently is the one behaviour this whole file exists to prevent.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static CngKey OpenOrCreate()
|
private static CngKey OpenOrCreate(string name)
|
||||||
{
|
{
|
||||||
var provider = new CngProvider(PlatformProvider);
|
var provider = new CngProvider(PlatformProvider);
|
||||||
|
|
||||||
if (CngKey.Exists(KeyName, provider))
|
if (CngKey.Exists(name, provider))
|
||||||
{
|
{
|
||||||
return CngKey.Open(KeyName, provider);
|
return CngKey.Open(name, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CngKey.Create(
|
return CngKey.Create(
|
||||||
CngAlgorithm.Rsa,
|
CngAlgorithm.Rsa,
|
||||||
KeyName,
|
name,
|
||||||
new CngKeyCreationParameters
|
new CngKeyCreationParameters
|
||||||
{
|
{
|
||||||
Provider = provider,
|
Provider = provider,
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers this executable as the handler for <c>dodossh://</c> links, for this user, on Windows.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Under HKCU\Software\Classes, because the install is per user.</b> Velopack installs into the user's
|
||||||
|
/// own profile with no elevation, and a handler registered machine-wide would need the elevation the
|
||||||
|
/// installer deliberately never asks for. Windows merges this key into <c>HKEY_CLASSES_ROOT</c> for the
|
||||||
|
/// user, which is all a browser consults.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Written by Velopack's install and update hooks, removed by its uninstall hook</b> — see
|
||||||
|
/// <c>Program.Main</c>. An update rewrites it because the first update to a build that has this is how an
|
||||||
|
/// existing installation gets it at all, and because it costs nothing to say again.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Removed only when it is still ours.</b> The release and the nightly are separate installations that
|
||||||
|
/// both answer <c>dodossh://</c>, and the last one installed holds the key. Uninstalling the other must not
|
||||||
|
/// take the handler away from the one that is still there.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal static class WindowsUrlScheme
|
||||||
|
{
|
||||||
|
private const string KeyPath = @"Software\Classes\" + SpelunkLink.Scheme;
|
||||||
|
private const string CommandPath = @"shell\open\command";
|
||||||
|
|
||||||
|
/// <summary>Points <c>dodossh://</c> at <paramref name="executable"/>.</summary>
|
||||||
|
internal static void Register(string executable)
|
||||||
|
{
|
||||||
|
using var scheme = Registry.CurrentUser.CreateSubKey(KeyPath);
|
||||||
|
|
||||||
|
scheme.SetValue(string.Empty, "URL:DodoSSH");
|
||||||
|
|
||||||
|
// Empty, and its presence is the whole of it: this value is what makes the key a URL scheme rather
|
||||||
|
// than a file type.
|
||||||
|
scheme.SetValue("URL Protocol", string.Empty);
|
||||||
|
|
||||||
|
using (var icon = scheme.CreateSubKey("DefaultIcon"))
|
||||||
|
{
|
||||||
|
icon.SetValue(string.Empty, $"\"{executable}\",0");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var command = scheme.CreateSubKey(CommandPath);
|
||||||
|
|
||||||
|
command.SetValue(string.Empty, CommandFor(executable));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes the handler away, if it is still <paramref name="executable"/>'s.</summary>
|
||||||
|
internal static void Unregister(string executable)
|
||||||
|
{
|
||||||
|
string? registered;
|
||||||
|
|
||||||
|
using (var command = Registry.CurrentUser.OpenSubKey($@"{KeyPath}\{CommandPath}"))
|
||||||
|
{
|
||||||
|
registered = command?.GetValue(string.Empty) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsOurs(registered, executable))
|
||||||
|
{
|
||||||
|
Registry.CurrentUser.DeleteSubKeyTree(KeyPath, throwOnMissingSubKey: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The command Windows runs for a link: this executable, with the link as its one argument.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves quoted. The path has spaces in it on any profile whose user name does, and the link is
|
||||||
|
/// handed over whole as <c>%1</c> — which Windows quotes into one argument only because it is quoted
|
||||||
|
/// here. Program.Main then reads it back and checks it before anything acts on it.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string CommandFor(string executable) => $"\"{executable}\" \"%1\"";
|
||||||
|
|
||||||
|
/// <summary>Whether a registered command is the one <see cref="Register"/> writes for this executable.</summary>
|
||||||
|
internal static bool IsOurs(string? registered, string executable) =>
|
||||||
|
string.Equals(registered, CommandFor(executable), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -8,6 +8,16 @@ namespace DodoSSH.Client.App;
|
|||||||
|
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
|
||||||
|
/// ordinary launch.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
|
||||||
|
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
|
||||||
|
/// with no arguments to have.
|
||||||
|
/// </remarks>
|
||||||
|
internal static SpelunkLink? LaunchLink { get; private set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point.
|
/// Entry point.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -29,11 +39,12 @@ internal static class Program
|
|||||||
// and a silent installer run that reached either would be a background process holding the cache
|
// and a silent installer run that reached either would be a background process holding the cache
|
||||||
// file open during the very file operations the installer is performing.
|
// file open during the very file operations the installer is performing.
|
||||||
//
|
//
|
||||||
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
|
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
|
||||||
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
|
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
|
||||||
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
|
// after an update — a schema migration — already runs on every ordinary launch from
|
||||||
// before unlock and touching no encrypted content.
|
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
|
||||||
VelopackApp.Build().Run();
|
// have touch only the registry; see RunVelopack.
|
||||||
|
RunVelopack();
|
||||||
|
|
||||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||||
|
|
||||||
@@ -51,16 +62,103 @@ internal static class Program
|
|||||||
*/
|
*/
|
||||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
|
|
||||||
using var claim = SingleInstance.Claim(paths);
|
// Read before the claim, because a launch that carries a link and finds DodoSSH already running
|
||||||
|
// passes the link on rather than merely asking for the window.
|
||||||
|
var link = LinkFrom(args);
|
||||||
|
|
||||||
|
using var claim = SingleInstance.Claim(paths, link: link);
|
||||||
|
|
||||||
if (claim is null)
|
if (claim is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchLink = link;
|
||||||
|
|
||||||
|
// After the claim, so a second launch that only hands its link over writes nothing.
|
||||||
|
CrashLog.Install(paths);
|
||||||
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
|
||||||
|
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
|
||||||
|
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
|
||||||
|
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
|
||||||
|
/// <see cref="WindowsUrlScheme"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RunVelopack()
|
||||||
|
{
|
||||||
|
var velopack = VelopackApp.Build();
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
velopack = velopack
|
||||||
|
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
|
||||||
|
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
|
||||||
|
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
velopack.Run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
|
||||||
|
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
|
||||||
|
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
|
||||||
|
/// </remarks>
|
||||||
|
private static SpelunkLink? LinkFrom(string[] args) =>
|
||||||
|
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
|
||||||
|
&& SpelunkLink.TryParse(candidate, out var link)
|
||||||
|
? link
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
|
||||||
|
/// convenience could not be set up would be the wrong trade — the application works without it, and the
|
||||||
|
/// only thing lost is Spelunk's button opening it.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RegisterUrlScheme()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WindowsUrlScheme.Register(executable);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
// See the remarks.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
|
||||||
|
private static void UnregisterUrlScheme()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WindowsUrlScheme.Unregister(executable);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
// See the remarks on RegisterUrlScheme.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells a drawn titlebar's double-click from a press that starts a drag.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
|
||||||
|
/// The two do not mix: <c>BeginMoveDrag</c> hands the press to the operating system's own move loop, which
|
||||||
|
/// eats the release — and a double tap is a gesture Avalonia assembles from presses <em>and</em> releases,
|
||||||
|
/// so the second click often never completed one and the window did not maximise. The convention it was
|
||||||
|
/// imitating is one nobody thinks about until it does not work.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
|
||||||
|
/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
|
||||||
|
/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class CaptionPresses
|
||||||
|
{
|
||||||
|
private ulong lastAt;
|
||||||
|
|
||||||
|
private Point lastWhere;
|
||||||
|
|
||||||
|
private bool waitingForASecond;
|
||||||
|
|
||||||
|
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
|
||||||
|
internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
|
||||||
|
{
|
||||||
|
var settings = Application.Current?.PlatformSettings;
|
||||||
|
var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
|
||||||
|
var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
|
||||||
|
var where = e.GetPosition(bar);
|
||||||
|
|
||||||
|
var second = e.ClickCount == 2
|
||||||
|
|| (waitingForASecond
|
||||||
|
&& e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
|
||||||
|
&& Math.Abs(where.X - lastWhere.X) <= size.Width
|
||||||
|
&& Math.Abs(where.Y - lastWhere.Y) <= size.Height);
|
||||||
|
|
||||||
|
// A double-click spends both presses: a third that follows quickly starts again rather than toggling
|
||||||
|
// the window straight back.
|
||||||
|
waitingForASecond = !second;
|
||||||
|
lastAt = e.Timestamp;
|
||||||
|
lastWhere = where;
|
||||||
|
|
||||||
|
return second;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class ConfirmDeleteCard : UserControl
|
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||||
{
|
{
|
||||||
public ConfirmDeleteCard() => InitializeComponent();
|
public ConfirmDeleteCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
Width="420" SizeToContent="Height"
|
Width="420" SizeToContent="Height"
|
||||||
CanResize="False" ShowInTaskbar="False"
|
CanResize="False" ShowInTaskbar="False"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Background="{StaticResource Surface}"
|
Background="{StaticResource Panel}"
|
||||||
Title="DodoSSH">
|
Title="DodoSSH">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
|
<TextBlock Foreground="{StaticResource TextDim}" FontSize="12" TextWrapping="Wrap"
|
||||||
x:Name="Explanation" />
|
x:Name="Explanation" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ public sealed partial class DevicePinWindow : Window
|
|||||||
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
||||||
public DevicePinWindow()
|
public DevicePinWindow()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
// InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
|
||||||
|
// fields below, and with Load the first of them was null — so this window threw on construction and
|
||||||
|
// no Linux PIN prompt was ever shown.
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
Accept.Click += OnAccept;
|
Accept.Click += OnAccept;
|
||||||
Cancel.Click += OnCancel;
|
Cancel.Click += OnCancel;
|
||||||
|
|||||||
@@ -253,8 +253,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
|
||||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.Template>
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ItemsControl.Template>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
||||||
<Border Classes="importrow" CornerRadius="8">
|
<Border Classes="importrow" CornerRadius="8">
|
||||||
@@ -290,8 +302,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -578,7 +578,8 @@
|
|||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,8,78">
|
<Grid ColumnDefinitions="*,8,78">
|
||||||
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||||
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||||
@@ -599,6 +600,7 @@
|
|||||||
-->
|
-->
|
||||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||||
|
IsEnabled="{Binding EditorAsksForLogin}"
|
||||||
HorizontalAlignment="Stretch" Height="40">
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
@@ -612,6 +614,15 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Why the two controls above are greyed out for a host Spelunk signs in to: disabled rather
|
||||||
|
than hidden, so a login somebody chose is still there to see and comes back if the box in
|
||||||
|
the Spelunk card is unticked before saving. See VaultViewModel.EditorAsksForLogin.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding !EditorAsksForLogin}"
|
||||||
|
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||||
@@ -692,6 +703,66 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
â—† THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
|
||||||
|
because a host has one route. A host ticked here is reached through the Spelunk agent already
|
||||||
|
on the server, so it needs no open SSH port at all. The fields appear only when the box is
|
||||||
|
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
|
||||||
|
and synced like every other secret. See VaultViewModel.Spelunk.cs.
|
||||||
|
-->
|
||||||
|
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" VerticalContentAlignment="Center">
|
||||||
|
<TextBlock Text="Connect through Spelunk" FontSize="13"
|
||||||
|
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding EditorThroughSpelunk}">
|
||||||
|
<TextBox Text="{Binding EditorSpelunkApi}" Height="36"
|
||||||
|
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||||
|
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
|
||||||
|
PlaceholderText="Spelunk server id" />
|
||||||
|
<!--
|
||||||
|
Saved Spelunk keys first (Settings → Security), each qualified by the one address it
|
||||||
|
goes to, then credentials whose password is a key. A deleted one keeps a placeholder, as
|
||||||
|
the authentication picker's does, so an unrelated edit cannot drop it. Typing an address
|
||||||
|
a key is saved for picks that key when nothing is picked yet.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="10.5" Text="Spelunk API key" />
|
||||||
|
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||||
|
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||||
|
HorizontalAlignment="Stretch" Height="40">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Text="{Binding Label}" />
|
||||||
|
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
<!--
|
||||||
|
Spelunk signs the user in: a key pair made in memory for each connection, its public
|
||||||
|
half sent with the ticket, and the agent's one-connection SSH server accepting only
|
||||||
|
that. Ticking it greys out the login above. See SpelunkRoute.SignInThroughSpelunk.
|
||||||
|
-->
|
||||||
|
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" VerticalContentAlignment="Center">
|
||||||
|
<TextBlock Text="Sign in through Spelunk (no SSH key needed)" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
</StackPanel>
|
||||||
|
<!--
|
||||||
|
What the route asks for and what it costs, said where the decision is made: an Admin-role
|
||||||
|
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
|
||||||
|
only while Spelunk and its agent are.
|
||||||
|
-->
|
||||||
|
<TextBlock Classes="hint" FontSize="11.5"
|
||||||
|
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save your Spelunk API key (Admin role) under Settings → Security and choose it here, or keep it as a credential's password; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||||
key is refused outright with no way to continue past it — so a legitimately rebuilt server
|
key is refused outright with no way to continue past it — so a legitimately rebuilt server
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||||
variant of a surface; the pre-multiplied ones there are accent washes.
|
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||||
-->
|
-->
|
||||||
<Border Background="#CC05050A">
|
<!--
|
||||||
|
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
|
||||||
|
that cannot be answered by clicking away should not be answerable by tabbing away either.
|
||||||
|
-->
|
||||||
|
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||||
Command="{Binding TrustHostKeyCommand}" />
|
Command="{Binding TrustHostKeyCommand}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -119,7 +123,7 @@
|
|||||||
is not an answer, because this half of the control has none to give. Compare the accent button
|
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||||
above, which connects.
|
above, which connects.
|
||||||
-->
|
-->
|
||||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||||
Command="{Binding RejectHostKeyCommand}" />
|
Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
internal sealed partial class HostKeyCard : UserControl
|
internal sealed partial class HostKeyCard : UserControl
|
||||||
{
|
{
|
||||||
public HostKeyCard() => InitializeComponent();
|
public HostKeyCard() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> gives about every confirmation
|
||||||
|
/// and with more force here: an Enter meant for whatever was focused before the card appeared must not
|
||||||
|
/// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
|
||||||
|
/// this one is raised over a terminal that may be holding it natively — see
|
||||||
|
/// <c>MainWindow.OnHostKeyDecision</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
|
||||||
}
|
}
|
||||||
@@ -230,9 +230,13 @@
|
|||||||
|
|
||||||
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
||||||
who has found this box should know about the one that also connects on Enter.
|
who has found this box should know about the one that also connects on Enter.
|
||||||
|
|
||||||
|
One way, and passed back by the code-behind once the typing pauses: every change to the filter
|
||||||
|
rebuilds the board's sections and their cards, and a name typed at speed used to do that once a
|
||||||
|
letter. The view model still filters the moment it is told. See HostsScreen.OnFilterTyped.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="1" Margin="26,0,26,16">
|
<Border Grid.Row="1" Margin="26,0,26,16">
|
||||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
|
<TextBox x:Name="HostFilter" Text="{Binding HostFilter, Mode=OneWay}" Height="40" CornerRadius="10"
|
||||||
FontFamily="{StaticResource MonoFont}"
|
FontFamily="{StaticResource MonoFont}"
|
||||||
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -573,8 +577,9 @@
|
|||||||
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
|
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
|
||||||
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
|
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
|
||||||
|
|
||||||
|
<!-- Builds only the cards near the view; see VirtualizingWrapPanel. -->
|
||||||
<ListBox.ItemsPanel>
|
<ListBox.ItemsPanel>
|
||||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
<ItemsPanelTemplate><views:VirtualizingWrapPanel /></ItemsPanelTemplate>
|
||||||
</ListBox.ItemsPanel>
|
</ListBox.ItemsPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -683,7 +688,7 @@
|
|||||||
TextTrimming never fires and the name would run on past the card's own
|
TextTrimming never fires and the name would run on past the card's own
|
||||||
border instead of trimming. See the longer note this carried in v4.
|
border instead of trimming. See the longer note this carried in v4.
|
||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
||||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
@@ -708,17 +713,27 @@
|
|||||||
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||||
Width="9" Height="9" />
|
Width="9" Height="9" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
<!--
|
||||||
|
"not synced" and its kin sit on the address line. Beside the name, where it
|
||||||
|
used to be, it took seventy of the card's pixels from the one thing a card
|
||||||
|
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
|
||||||
|
both drew as "k8s…". The address can give the room up — it trims, and the
|
||||||
|
tooltip and the drawer both carry it whole.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource TextGhost}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
|
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
|
||||||
Foreground="{StaticResource TextGhost}"
|
|
||||||
TextTrimming="CharacterEllipsis" />
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Which vault this host is in, and only when there is more than one to be
|
Which vault this host is in, and only when there is more than one to be
|
||||||
in. It decides who else can see the host and where an edit goes back to,
|
in. It decides who else can see the host and where an edit goes back to,
|
||||||
@@ -734,8 +749,13 @@
|
|||||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
||||||
Margin="0,8,0,0">
|
Margin="0,8,0,0">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4"
|
<!--
|
||||||
VerticalAlignment="Bottom">
|
A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
|
||||||
|
measures its children with infinite width, so the tags' WrapPanel below
|
||||||
|
never wrapped and ran past the card's edge; the last child of a DockPanel
|
||||||
|
gets the width that is actually left, and wraps in it.
|
||||||
|
-->
|
||||||
|
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The pin badge: how many folders are pinned on this host, reading
|
The pin badge: how many folders are pinned on this host, reading
|
||||||
@@ -743,8 +763,8 @@
|
|||||||
property's own remarks. Only when there is at least one; a badge reading
|
property's own remarks. Only when there is at least one; a badge reading
|
||||||
zero would be a fact nobody asked for.
|
zero would be a fact nobody asked for.
|
||||||
-->
|
-->
|
||||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
<Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
|
||||||
IsVisible="{Binding HasPins}">
|
Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||||
@@ -767,12 +787,13 @@
|
|||||||
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
||||||
Margin="0,0,4,4">
|
Margin="0,0,4,4">
|
||||||
<TextBlock Text="{Binding}" FontSize="11"
|
<TextBlock Text="{Binding}" FontSize="11"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
HostRowViewModel.LastConnectedText is filled from the connection log on
|
HostRowViewModel.LastConnectedText is filled from the connection log on
|
||||||
|
|||||||
@@ -106,6 +106,48 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
WireTheBand();
|
WireTheBand();
|
||||||
|
|
||||||
|
HostFilter.TextChanged += OnFilterTyped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
|
||||||
|
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
|
||||||
|
|
||||||
|
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
|
||||||
|
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
|
||||||
|
|
||||||
|
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
|
||||||
|
private int typed;
|
||||||
|
|
||||||
|
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
|
||||||
|
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
|
||||||
|
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
|
||||||
|
/// change it makes to the box comes back here as the same text, which changes nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
|
||||||
|
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
|
||||||
|
|
||||||
|
private async Task PassTheFilterOnAsync(string text, int keystroke)
|
||||||
|
{
|
||||||
|
await Pause(FilterPause).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// A later letter has its own wait, and it is the one that speaks.
|
||||||
|
if (keystroke != typed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
vault.HostFilter = text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||||
@@ -122,6 +164,13 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||||
Board.KeyDown += OnGridKey;
|
Board.KeyDown += OnGridKey;
|
||||||
|
|
||||||
|
// The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See
|
||||||
|
// OnCardSelectionChanged for why this is not simply a two-way binding.
|
||||||
|
Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged);
|
||||||
|
|
||||||
|
// Tunnelled, because a section's list answers Enter itself and nothing bubbles past it.
|
||||||
|
Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
// â—† The band is the scroller's rather than any one section list's, because the space it is dragged
|
// â—† The band is the scroller's rather than any one section list's, because the space it is dragged
|
||||||
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
||||||
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
||||||
@@ -315,6 +364,16 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An arrow with the board itself focused — which is where this screen puts the keyboard, see
|
||||||
|
// KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever
|
||||||
|
// reaches a section's list, since none of them has the keyboard until a card has been clicked.
|
||||||
|
if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board))
|
||||||
|
{
|
||||||
|
e.Handled = EnterTheCards(vault);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
||||||
{
|
{
|
||||||
var order = vault.HostBoardOrder.ToList();
|
var order = vault.HostBoardOrder.ToList();
|
||||||
@@ -347,6 +406,80 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
|
||||||
|
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
|
||||||
|
/// while cards are ticked, where the selection is not what the set is about.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnCardEnter(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key is not Key.Enter
|
||||||
|
|| e.KeyModifiers != KeyModifiers.None
|
||||||
|
|| Vault is not { IsChoosingHosts: false } vault
|
||||||
|
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
|
||||||
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
|
||||||
|
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
|
||||||
|
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
|
||||||
|
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
|
||||||
|
/// right-click handler exists to prevent.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// â—† Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
|
||||||
|
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
|
||||||
|
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
|
||||||
|
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault
|
||||||
|
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|
||||||
|
|| ReferenceEquals(row, vault.SelectedHost))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor = row;
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
|
||||||
|
private bool EnterTheCards(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
|
||||||
|
|
||||||
|
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
|
||||||
|
|
||||||
|
if (card?.DataContext is not HostRowViewModel row)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
anchor = row;
|
||||||
|
|
||||||
|
return card.Focus(NavigationMethod.Directional);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
EndBand();
|
EndBand();
|
||||||
|
|||||||
@@ -131,8 +131,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
|
||||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.Template>
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ItemsControl.Template>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||||
<Border Classes="importrow" CornerRadius="8">
|
<Border Classes="importrow" CornerRadius="8">
|
||||||
@@ -161,8 +173,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -256,7 +256,8 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ============ The table ============ -->
|
<!-- ============ The table ============ -->
|
||||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}">
|
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"
|
||||||
|
Grid.IsSharedSizeScope="True">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="16,0" Height="44"
|
<Border Grid.Row="0" Padding="16,0" Height="44"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
@@ -275,12 +276,24 @@
|
|||||||
item this application stores carries a timestamp; see the file-level remark above and
|
item this application stores carries a timestamp; see the file-level remark above and
|
||||||
docs/design-import-gaps.md.
|
docs/design-import-gaps.md.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
|
<!--
|
||||||
|
VAULT is sized to what is in it, and shared between this heading and every row so the two agree:
|
||||||
|
with one vault no row names one, and a fixed column was a heading over a stripe of nothing.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="1" Margin="0,10,16,6"
|
||||||
IsVisible="{Binding HasVaultItems}">
|
IsVisible="{Binding HasVaultItems}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="2" />
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="1.1*" />
|
||||||
|
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||||
|
<ColumnDefinition Width="1.2*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
||||||
Margin="12,0,8,0" />
|
Margin="12,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||||
|
Margin="0,0,16,0" IsVisible="{Binding ShowsVaultColumn}" />
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -290,18 +303,31 @@
|
|||||||
Margin="8,0,8,10">
|
Margin="8,0,8,10">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||||
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
|
<Grid Height="40" Margin="6,0,10,0">
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
|
<Grid.ColumnDefinitions>
|
||||||
VerticalAlignment="Center">
|
<ColumnDefinition Width="2" />
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="1.1*" />
|
||||||
|
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||||
|
<ColumnDefinition Width="1.2*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<!--
|
||||||
|
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
|
||||||
|
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
|
||||||
|
columns beside it.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||||
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||||
</StackPanel>
|
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
|
||||||
|
</Grid>
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" Margin="0,0,16,0"
|
||||||
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
||||||
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -383,7 +383,12 @@
|
|||||||
not replayed. See MainWindow.axaml.cs.
|
not replayed. See MainWindow.axaml.cs.
|
||||||
-->
|
-->
|
||||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||||
PlaceholderText="https://dodossh.example" />
|
PlaceholderText="https://dodossh.example">
|
||||||
|
<!-- Enter signs in, as it unlocks on the unlock card. -->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||||
@@ -395,8 +400,14 @@
|
|||||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||||
<TextBlock Classes="hint"
|
<TextBlock Classes="hint"
|
||||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
||||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
<TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
|
||||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
PasswordChar="•" />
|
||||||
|
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
|
||||||
|
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
|
|||||||
|
|
||||||
private bool wasUnlocked;
|
private bool wasUnlocked;
|
||||||
|
|
||||||
|
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
||||||
|
private ShellState? closedState;
|
||||||
|
|
||||||
|
private bool wasAskingAboutHostKey;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
ShellState.Locked => UnlockPane.PassphraseBox,
|
ShellState.Locked => UnlockPane.PassphraseBox,
|
||||||
ShellState.NeedsServer => ServerUrlBox,
|
ShellState.NeedsServer => ServerUrlBox,
|
||||||
|
ShellState.NeedsEnrollment => NewPassphraseBox,
|
||||||
_ => this,
|
_ => this,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,33 +232,96 @@ internal sealed partial class MainWindow : Window
|
|||||||
size.Execute(null);
|
size.Execute(null);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
// A host-key decision before the palette, because it is drawn over the palette: Escape is CANCEL or
|
||||||
|
// CLOSE, both of which refuse — never the answer that connects. See HostKeyCard.SafeAnswer.
|
||||||
|
else if (e.Key == Key.Escape && viewModel.IsHostKeyDecisionShowing)
|
||||||
|
{
|
||||||
|
viewModel.Vault?.RejectHostKeyCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
else if (viewModel.IsSearching)
|
else if (viewModel.IsSearching)
|
||||||
{
|
{
|
||||||
Palette.HandleKey(e);
|
Palette.HandleKey(e);
|
||||||
}
|
}
|
||||||
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
|
else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
|
||||||
// already follows one branch up. Checked after the palette rather than before it: the two states
|
|
||||||
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
|
|
||||||
// settings does not open the palette — but an Escape while both were somehow true should close the
|
|
||||||
// thing drawn on top, which is the palette.
|
|
||||||
//
|
|
||||||
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
|
|
||||||
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
|
|
||||||
// "Back to application" while IsImportOpen is true.
|
|
||||||
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
|
|
||||||
{
|
{
|
||||||
viewModel.CloseImportCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
|
||||||
{
|
|
||||||
viewModel.LeaveSettingsCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One rule throughout — closest thing first — so the order of these arms is the order things are drawn
|
||||||
|
/// over one another. A confirmation before anything, because it is a question in front of whatever page
|
||||||
|
/// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already
|
||||||
|
/// follows in the caller. Checked after the palette rather than before it: the two states are mutually
|
||||||
|
/// exclusive in practice — opening the palette does not enter settings mode and entering settings does
|
||||||
|
/// not open the palette — but an Escape while both were somehow true should close the thing drawn on
|
||||||
|
/// top, which is the palette.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own
|
||||||
|
/// back button follows by showing "Back to preferences" rather than "Back to application" while
|
||||||
|
/// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so
|
||||||
|
/// it goes first and settings stays.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its
|
||||||
|
/// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool CloseTheNearestThing(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (AnswerNoToTheQuestionShowing(viewModel))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var close = viewModel switch
|
||||||
|
{
|
||||||
|
{ IsImportOpen: true } => viewModel.CloseImportCommand,
|
||||||
|
{ IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand,
|
||||||
|
{ IsSettingsMode: true } => viewModel.LeaveSettingsCommand,
|
||||||
|
{ IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault }
|
||||||
|
=> vault.CloseDrawerCommand,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
close?.Execute(null);
|
||||||
|
|
||||||
|
return close is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a
|
||||||
|
/// question about destroying something. A deletion question left armed on a screen somebody navigated away
|
||||||
|
/// from is cancelled by it too, which is harmless — it is the same answer the question would get if it
|
||||||
|
/// were on screen — and cheaper than deciding which of the six places that card is drawn is showing.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (viewModel.IsConfirmingSignOut)
|
||||||
|
{
|
||||||
|
viewModel.CancelSignOutCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault })
|
||||||
|
{
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The text-size chords, when the terminal is not the thing hearing them.
|
/// The text-size chords, when the terminal is not the thing hearing them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -300,6 +369,15 @@ internal sealed partial class MainWindow : Window
|
|||||||
Terminal.Source = viewModel.TerminalPageUrl;
|
Terminal.Source = viewModel.TerminalPageUrl;
|
||||||
|
|
||||||
wasUnlocked = viewModel.IsUnlocked;
|
wasUnlocked = viewModel.IsUnlocked;
|
||||||
|
closedState = null;
|
||||||
|
wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing;
|
||||||
|
|
||||||
|
// The shell may have reached its card before this window was given it, and then no change would
|
||||||
|
// ever say so.
|
||||||
|
if (!viewModel.IsUnlocked)
|
||||||
|
{
|
||||||
|
FocusClosedVaultCard(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
@@ -358,6 +436,10 @@ internal sealed partial class MainWindow : Window
|
|||||||
OnSelectedTabChanged(viewModel);
|
OnSelectedTabChanged(viewModel);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
|
||||||
|
OnHostKeyDecision(viewModel);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -373,10 +455,77 @@ internal sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||||
}
|
}
|
||||||
|
else if (!unlocked && viewModel.State != closedState)
|
||||||
|
{
|
||||||
|
FocusClosedVaultCard(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
closedState = unlocked ? null : viewModel.State;
|
||||||
wasUnlocked = unlocked;
|
wasUnlocked = unlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
|
||||||
|
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
|
||||||
|
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
|
||||||
|
/// raised for every state change, and the state does not change while they type. Posted, because the card
|
||||||
|
/// is revealed by a binding the same change has not reached yet.
|
||||||
|
/// </remarks>
|
||||||
|
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
closedState = viewModel.State;
|
||||||
|
|
||||||
|
var home = ClosedVaultKeyboardHome;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose
|
||||||
|
/// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and
|
||||||
|
/// Tab walked the page under it. Hence <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
|
||||||
|
/// posted, because the binding that reveals the card has not run yet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must
|
||||||
|
/// move once per question, not once per raise. On the way out the keyboard goes where the window would
|
||||||
|
/// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnHostKeyDecision(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
var asking = viewModel.IsHostKeyDecisionShowing;
|
||||||
|
|
||||||
|
if (asking == wasAskingAboutHostKey)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wasAskingAboutHostKey = asking;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asking)
|
||||||
|
{
|
||||||
|
ReleaseKeyboardTo(HostKeyPane.SafeAnswer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OnShowingSomethingElse(viewModel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands the keyboard to a confirmation's harmless answer when the question appears.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For the questions drawn in place on a page — the deletion and the sign-out confirmations — which used to
|
||||||
|
/// appear with the keyboard still on the button that asked them, or nowhere. Tab then walked the page behind
|
||||||
|
/// the question, Enter did nothing useful, and somebody working from the keyboard had to reach for the mouse
|
||||||
|
/// to say no.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// â—† <b>The harmless answer, never the one the question is about.</b> Enter on a focused button presses it,
|
||||||
|
/// so the button that takes the keyboard is the one an accidental Enter is allowed to press: CANCEL. Putting it
|
||||||
|
/// on DELETE or SIGN OUT AND DELETE would make two keystrokes — the one that asked and the one after — a way to
|
||||||
|
/// destroy something without reading the sentence in between.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Each card follows its own question rather than the window following all of them, because the same card is
|
||||||
|
/// drawn in several places and only the one somebody can see should take the keyboard — which the posted
|
||||||
|
/// check on <c>IsEffectivelyVisible</c> decides, after the binding that reveals it has run. Subscribed only
|
||||||
|
/// while the card is in the tree: settings pages are rebuilt as they are visited, and a card that subscribed
|
||||||
|
/// for good would leave one handler on the long-lived view model per visit.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SafeAnswerFocus
|
||||||
|
{
|
||||||
|
internal static void Follow(Control card, string question, Func<Control> safeAnswer)
|
||||||
|
{
|
||||||
|
INotifyPropertyChanged? watched = null;
|
||||||
|
|
||||||
|
void OnChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!string.Equals(e.PropertyName, question, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (safeAnswer() is { IsEffectivelyVisible: true } answer)
|
||||||
|
{
|
||||||
|
answer.Focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Watch(INotifyPropertyChanged? next)
|
||||||
|
{
|
||||||
|
if (watched is not null)
|
||||||
|
{
|
||||||
|
watched.PropertyChanged -= OnChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
watched = next;
|
||||||
|
|
||||||
|
if (watched is not null)
|
||||||
|
{
|
||||||
|
watched.PropertyChanged += OnChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.AttachedToVisualTree += (_, _) => Watch(card.DataContext as INotifyPropertyChanged);
|
||||||
|
card.DetachedFromVisualTree += (_, _) => Watch(null);
|
||||||
|
card.DataContextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (card.IsAttachedToVisualTree())
|
||||||
|
{
|
||||||
|
Watch(card.DataContext as INotifyPropertyChanged);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,14 @@
|
|||||||
Classes.connecting="{Binding IsConnecting}"
|
Classes.connecting="{Binding IsConnecting}"
|
||||||
Classes.dropped="{Binding IsDropped}"
|
Classes.dropped="{Binding IsDropped}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
<!--
|
||||||
|
Bounded. A label is whatever the host was called, or the address typed into the palette,
|
||||||
|
and one long one used to push every tab after it and the "+" past the strip's edge. The
|
||||||
|
whole of it is on the label's own tooltip; the tab's is the address, as before.
|
||||||
|
-->
|
||||||
|
<TextBlock Text="{Binding Label}" VerticalAlignment="Center"
|
||||||
|
MaxWidth="200" TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding Label}" />
|
||||||
|
|
||||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
<Border Classes="settingscard">
|
<Border Classes="settingscard">
|
||||||
<StackPanel Margin="24,20" Spacing="10">
|
<StackPanel Margin="24,20" Spacing="10">
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
Text="Unlocking with this machine's device key moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
|
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage"
|
x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage"
|
||||||
x:DataType="vm:MainWindowViewModel">
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
x:Name="Root">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
v5c: Security, against Settings-Security.dc.html.
|
v5c: Security, against Settings-Security.dc.html.
|
||||||
@@ -24,6 +26,14 @@
|
|||||||
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
|
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
|
||||||
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
|
ShowScreenCommand already does that for any target that is not Preferences or Vaults.
|
||||||
|
|
||||||
|
SPELUNK is where a Spelunk API key is filled in: an address and a key, saved as a Spelunk key item in the
|
||||||
|
personal vault (VaultViewModel.SaveSpelunkKeyCommand) and synced like every secret. The address is the
|
||||||
|
point — a key saved here is only ever sent there, and links from that Spelunk connect straight away
|
||||||
|
because saving it is the user saying so. A second key for an address already saved replaces the first.
|
||||||
|
The key box is a password box and is emptied the moment the key is in the keychain; the list below it
|
||||||
|
shows the name, the address and how many hosts use each key, never the key. Deleting goes through the
|
||||||
|
same ConfirmDeleteCard every other deletion does, with a count of the hosts that would stop connecting.
|
||||||
|
|
||||||
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
|
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
|
||||||
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
|
building a filtered read over LogsViewModel's activity and connection logs — which entries count as
|
||||||
"security" is itself a judgement call the design does not resolve — and the honest link is complete on
|
"security" is itself a judgement call the design does not resolve — and the honest link is complete on
|
||||||
@@ -36,19 +46,21 @@
|
|||||||
<TextBlock Classes="settingstitle" Text="Security" />
|
<TextBlock Classes="settingstitle" Text="Security" />
|
||||||
|
|
||||||
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
<!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
|
||||||
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
|
||||||
|
Margin="0,0,16,0" VerticalAlignment="Top">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
||||||
Foreground="{StaticResource Live}"
|
Foreground="{StaticResource Live}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
|
||||||
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||||
Foreground="{StaticResource Text}" />
|
Foreground="{StaticResource Text}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
||||||
@@ -58,15 +70,15 @@
|
|||||||
<Border Classes="settingsrow last">
|
<Border Classes="settingsrow last">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
||||||
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" />
|
<TextBlock Classes="settingsrowtitle" Text="{Binding DeviceUnlock.Name}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working."
|
Text="{Binding DeviceUnlock.Offer}"
|
||||||
IsVisible="{Binding CanRegisterDevice}" />
|
IsVisible="{Binding CanRegisterDevice}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation."
|
Text="{Binding DeviceUnlock.Registered}"
|
||||||
IsVisible="{Binding CanForgetDevice}" />
|
IsVisible="{Binding CanForgetDevice}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
Text="{Binding DeviceUnlock.Unavailable}"
|
||||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
|
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
|
||||||
@@ -101,6 +113,86 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<TextBlock Classes="settingssection" Text="SPELUNK" />
|
||||||
|
|
||||||
|
<Border Classes="settingscard">
|
||||||
|
<StackPanel>
|
||||||
|
<Border Classes="settingsrow">
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="Spelunk API key" />
|
||||||
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
|
Text="Hosts reached through Spelunk use this key, and links from that Spelunk connect straight away. It is kept encrypted in your personal vault, syncs to your other devices, and is only ever sent to the address below. Use a key with the Admin role." />
|
||||||
|
</StackPanel>
|
||||||
|
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="10"
|
||||||
|
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
|
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkKeyFormApi}"
|
||||||
|
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||||
|
<TextBox Grid.Column="1" Height="38" PasswordChar="•" Text="{Binding Vault.SpelunkKeyFormKey}"
|
||||||
|
PlaceholderText="API key (spelunk_…)">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveSpelunkKeyCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
<Button Grid.Column="2" Classes="accent" Height="38" Content="SAVE KEY"
|
||||||
|
Command="{Binding Vault.SaveSpelunkKeyCommand}"
|
||||||
|
IsEnabled="{Binding !Vault.IsBusy, FallbackValue=False}" />
|
||||||
|
</Grid>
|
||||||
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
|
Text="{Binding Vault.SpelunkKeyFormNote}"
|
||||||
|
IsVisible="{Binding Vault.HasSpelunkKeyFormNote, FallbackValue=False}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||||
|
Text="Unlock your keychain to save a Spelunk key." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- The delete confirmation, in place, as the tags and groups pages draw it. -->
|
||||||
|
<Border Margin="24,12" Padding="20" CornerRadius="12"
|
||||||
|
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||||
|
BorderThickness="1"
|
||||||
|
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}">
|
||||||
|
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<ItemsControl ItemsSource="{Binding Vault.SpelunkKeys}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<StackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:SpelunkKeyRowViewModel">
|
||||||
|
<Border Classes="settingsrow" Padding="24,14">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<TextBlock Classes="settingsrowtitle" Text="{Binding Label}" />
|
||||||
|
<Border Classes="chip" IsVisible="{Binding HasVaultBadge}" VerticalAlignment="Center">
|
||||||
|
<TextBlock Text="{Binding VaultBadge}" />
|
||||||
|
</Border>
|
||||||
|
<TextBlock Classes="hint" FontSize="11" Text="{Binding Badge}" VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding ApiBase}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
|
||||||
|
Text="{Binding UsedBy}" />
|
||||||
|
<Button Grid.Column="2" Classes="paneicon danger" Width="30" Height="30" FontSize="13"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteSpelunkKeyRowCommand}"
|
||||||
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="Delete this key. Hosts that reach Spelunk with it stop connecting until they name another.">
|
||||||
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
|
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
|
||||||
|
|
||||||
<Border Classes="settingscard" Margin="0,12,0,40">
|
<Border Classes="settingscard" Margin="0,12,0,40">
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ using Avalonia.Controls;
|
|||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys.</summary>
|
/// <summary>
|
||||||
|
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys.
|
||||||
|
/// </summary>
|
||||||
internal sealed partial class SettingsSecurityPage : UserControl
|
internal sealed partial class SettingsSecurityPage : UserControl
|
||||||
{
|
{
|
||||||
public SettingsSecurityPage() => InitializeComponent();
|
public SettingsSecurityPage() => InitializeComponent();
|
||||||
|
|||||||
@@ -81,11 +81,15 @@
|
|||||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10">
|
<!--
|
||||||
|
No VAULT column, although the design draws one: TagRowViewModel does not carry which vault a tag
|
||||||
|
lives in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark), so
|
||||||
|
the column was a heading over nothing on every row.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="1.4*,2*,Auto" Margin="20,14,20,10">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
|
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
|
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" />
|
<TextBlock Grid.Column="2" Classes="label" Text=" " />
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text=" " />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Vault.Tags}">
|
<ItemsControl ItemsSource="{Binding Vault.Tags}">
|
||||||
@@ -97,7 +101,7 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TagRowViewModel">
|
<DataTemplate x:DataType="vm:TagRowViewModel">
|
||||||
<Border Classes="settingsrow last" Padding="20,10">
|
<Border Classes="settingsrow last" Padding="20,10">
|
||||||
<Grid ColumnDefinitions="1.4*,*,*,Auto">
|
<Grid ColumnDefinitions="1.4*,2*,Auto">
|
||||||
|
|
||||||
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
|
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding Label}" />
|
<TextBlock Text="{Binding Label}" />
|
||||||
@@ -107,14 +111,7 @@
|
|||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
Text="{Binding Description}" />
|
Text="{Binding Description}" />
|
||||||
|
|
||||||
<!--
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
|
|
||||||
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
|
|
||||||
so there is no second vault this column could honestly name.
|
|
||||||
-->
|
|
||||||
<TextBlock Grid.Column="2" />
|
|
||||||
|
|
||||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
|
||||||
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
|
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
|
||||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
|
||||||
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
|
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
PointerPressed="OnDrag">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
|
|||||||
{
|
{
|
||||||
public SettingsTitleBar() => InitializeComponent();
|
public SettingsTitleBar() => InitializeComponent();
|
||||||
|
|
||||||
|
private readonly CaptionPresses presses = new();
|
||||||
|
|
||||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|
||||||
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
|
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
|
||||||
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decided on the press, before the drag would take it: see CaptionPresses.
|
||||||
|
if (presses.IsSecond(e, this))
|
||||||
|
{
|
||||||
|
OnToggleMaximised(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Host?.BeginMoveDrag(e);
|
Host?.BeginMoveDrag(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +42,7 @@ internal sealed partial class SettingsTitleBar : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
|
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
|
||||||
|
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
|
||||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (Host is not { } window)
|
if (Host is not { } window)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
||||||
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
||||||
internal sealed partial class SignOutCard : UserControl
|
internal sealed partial class SignOutCard : UserControl
|
||||||
{
|
{
|
||||||
public SignOutCard() => InitializeComponent();
|
public SignOutCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -167,8 +167,12 @@
|
|||||||
TextAlignment="Center" MaxWidth="360"
|
TextAlignment="Center" MaxWidth="360"
|
||||||
IsVisible="{Binding !HasVisible}" />
|
IsVisible="{Binding !HasVisible}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Only with a snippet chosen. Three greyed buttons under a list nobody has clicked in read as
|
||||||
|
something broken rather than something waiting, and the pane on the right already says what to do.
|
||||||
|
-->
|
||||||
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
|
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
|
||||||
BorderThickness="0,1,0,0">
|
BorderThickness="0,1,0,0" IsVisible="{Binding HasSelection}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
|
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
|
||||||
IsEnabled="{Binding HasSelection}" />
|
IsEnabled="{Binding HasSelection}" />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
PointerPressed="OnDrag">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly CaptionPresses presses = new();
|
||||||
|
|
||||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decided on the press, before the drag would take it: see CaptionPresses.
|
||||||
|
if (presses.IsSecond(e, this))
|
||||||
|
{
|
||||||
|
OnToggleMaximised(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Host?.BeginMoveDrag(e);
|
Host?.BeginMoveDrag(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
|
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
|
||||||
/// the one people reach for without thinking about it.
|
/// the one people reach for without thinking about it. The double-click comes from <see cref="OnDrag"/>
|
||||||
|
/// rather than <c>DoubleTapped</c>; see <see cref="CaptionPresses"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1190,8 +1190,24 @@
|
|||||||
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="2">
|
<!--
|
||||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
Virtualised: a folder download queues a row per file, and a stack panel inside an outer scroller
|
||||||
|
laid out and drew every one of them, thousands deep. The scroller is the control's own so the panel
|
||||||
|
knows the viewport it has to fill and realises only those rows.
|
||||||
|
-->
|
||||||
|
<ItemsControl Grid.Row="2" ItemsSource="{Binding Transfers}">
|
||||||
|
<ItemsControl.Template>
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ItemsControl.Template>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||||
<!--
|
<!--
|
||||||
@@ -1239,7 +1255,6 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
|
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
|
||||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
|
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
|
||||||
|
|||||||
@@ -44,15 +44,15 @@
|
|||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<!--
|
<!--
|
||||||
Shown only when this machine has both a registered wrap and a keystore still willing to release the
|
Shown only when this machine has both a registered wrap and a keystore still willing to release the
|
||||||
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never
|
key. Absent rather than disabled, because a greyed-out "Use Touch ID" on a machine that never
|
||||||
had it invites the reading that something is broken — and the passphrase box beside it is not a
|
had it invites the reading that something is broken — and the passphrase box beside it is not a
|
||||||
fallback, it is the ordinary way in.
|
fallback, it is the ordinary way in.
|
||||||
-->
|
-->
|
||||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
<Button Classes="ghost" Content="{Binding DeviceUnlock.Button}"
|
||||||
Command="{Binding UnlockWithDeviceCommand}"
|
Command="{Binding UnlockWithDeviceCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
IsEnabled="{Binding !IsBusy}"
|
||||||
IsVisible="{Binding CanUnlockWithDevice}"
|
IsVisible="{Binding CanUnlockWithDevice}"
|
||||||
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
|
ToolTip.Tip="{Binding DeviceUnlock.Tip}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A wrap panel that builds only the items near the part of it on screen.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The hosts board is a list per section, each laid out in rows of cards. It was a plain
|
||||||
|
/// <see cref="WrapPanel"/>, which builds, measures and draws every card on the board whether it is in view or
|
||||||
|
/// not — some twenty-five controls a card, so a keychain of three hundred hosts took seconds to open and to
|
||||||
|
/// redraw after a filter was cleared, and a thousand took several times that. Avalonia has no virtualising
|
||||||
|
/// wrap panel of its own; this is one, for items of a fixed width.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Widths are the first item's; heights are per row.</b> A card's width is set by its style, so the
|
||||||
|
/// number of columns is arithmetic. Its height is not quite — a card wearing many tags grows a line — so each
|
||||||
|
/// row's height is the tallest card built in it, and a row not yet built is assumed to be as tall as the last
|
||||||
|
/// one measured. When a row turns out different the rows below it move, which is the ordinary price of
|
||||||
|
/// estimating, and is only ever seen while scrolling into rows nobody has looked at yet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What is on screen is read from the effective viewport</b>, which is the part of this panel the
|
||||||
|
/// scroller around the whole board shows — the section's own list never scrolls, so its own scroller has
|
||||||
|
/// nothing to say. A section scrolled out of view builds nothing at all past the one card measured for size.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Everything else a list does is left to the list. Containers come from its generator and are prepared by
|
||||||
|
/// it, so selection, the card template, the context menu and the band all work on the containers that exist.
|
||||||
|
/// A container holding the keyboard is never put away while it does, so arrowing past the edge of the view
|
||||||
|
/// does not drop the focus on the floor.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class VirtualizingWrapPanel : VirtualizingPanel
|
||||||
|
{
|
||||||
|
/// <summary>How far past the visible part, above and below, items are built ahead of being scrolled to.</summary>
|
||||||
|
private const double Overscan = 240;
|
||||||
|
|
||||||
|
/// <summary>What a row is assumed to be before anything in the panel has been measured.</summary>
|
||||||
|
private const double FallbackRowHeight = 124;
|
||||||
|
|
||||||
|
private readonly Dictionary<int, Control> realized = [];
|
||||||
|
|
||||||
|
private readonly Dictionary<object, Stack<Control>> recycled = [];
|
||||||
|
|
||||||
|
/// <summary>The pool each container this panel made goes back to; items that are their own containers are not in it.</summary>
|
||||||
|
private readonly Dictionary<Control, object> poolOf = [];
|
||||||
|
|
||||||
|
private readonly Dictionary<int, double> rowHeights = [];
|
||||||
|
|
||||||
|
private Rect viewport;
|
||||||
|
|
||||||
|
private bool viewportKnown;
|
||||||
|
|
||||||
|
private Size itemSize;
|
||||||
|
|
||||||
|
private int columns = 1;
|
||||||
|
|
||||||
|
/// <summary>An item asked to be scrolled to, kept built until a measure has seen it in range.</summary>
|
||||||
|
private int? pinned;
|
||||||
|
|
||||||
|
public VirtualizingWrapPanel()
|
||||||
|
{
|
||||||
|
EffectiveViewportChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (viewportKnown && e.EffectiveViewport == viewport)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewport = e.EffectiveViewport;
|
||||||
|
viewportKnown = true;
|
||||||
|
InvalidateMeasure();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Size MeasureOverride(Size availableSize)
|
||||||
|
{
|
||||||
|
var items = Items;
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
RecycleAllExcept(static _ => false);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemSize == default)
|
||||||
|
{
|
||||||
|
var first = RealizedOrNew(0);
|
||||||
|
first.Measure(Size.Infinity);
|
||||||
|
itemSize = first.DesiredSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
var width = double.IsInfinity(availableSize.Width)
|
||||||
|
? itemSize.Width * items.Count
|
||||||
|
: availableSize.Width;
|
||||||
|
|
||||||
|
var nowColumns = Math.Max(1, (int)Math.Floor(width / Math.Max(1, itemSize.Width)));
|
||||||
|
|
||||||
|
if (nowColumns != columns)
|
||||||
|
{
|
||||||
|
columns = nowColumns;
|
||||||
|
rowHeights.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tops = RowTops(items.Count);
|
||||||
|
var (firstIndex, lastIndex) = VisibleRange(tops, items.Count);
|
||||||
|
|
||||||
|
RecycleAllExcept(index =>
|
||||||
|
(index >= firstIndex && index <= lastIndex)
|
||||||
|
|| index == pinned
|
||||||
|
|| realized[index].IsKeyboardFocusWithin);
|
||||||
|
|
||||||
|
for (var index = firstIndex; index <= lastIndex; index++)
|
||||||
|
{
|
||||||
|
var container = RealizedOrNew(index);
|
||||||
|
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
|
||||||
|
|
||||||
|
var row = index / columns;
|
||||||
|
rowHeights[row] = Math.Max(rowHeights.GetValueOrDefault(row), container.DesiredSize.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinned is { } kept && kept >= firstIndex && kept <= lastIndex)
|
||||||
|
{
|
||||||
|
pinned = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tops = RowTops(items.Count);
|
||||||
|
|
||||||
|
return new Size(Math.Min(width, columns * itemSize.Width), tops[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Size ArrangeOverride(Size finalSize)
|
||||||
|
{
|
||||||
|
var tops = RowTops(Items.Count);
|
||||||
|
|
||||||
|
foreach (var (index, container) in realized)
|
||||||
|
{
|
||||||
|
container.Arrange(ItemRect(index, tops));
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnItemsChanged(items, e);
|
||||||
|
|
||||||
|
// Every change is answered as a reset. The board swaps its lists in whole, so an incremental answer
|
||||||
|
// would be code for a case that does not happen; and indexes, row heights and the pinned item all
|
||||||
|
// describe a list that is no longer the one being shown.
|
||||||
|
RecycleAllExcept(static _ => false);
|
||||||
|
rowHeights.Clear();
|
||||||
|
pinned = null;
|
||||||
|
InvalidateMeasure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Control? ScrollIntoView(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= Items.Count)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = RealizedOrNew(index);
|
||||||
|
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
|
||||||
|
container.Arrange(ItemRect(index, RowTops(Items.Count)));
|
||||||
|
|
||||||
|
pinned = index;
|
||||||
|
container.BringIntoView();
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Control? ContainerFromIndex(int index) => realized.GetValueOrDefault(index);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override int IndexFromContainer(Control container)
|
||||||
|
{
|
||||||
|
foreach (var (index, held) in realized)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(held, container))
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override IEnumerable<Control>? GetRealizedContainers() => realized.Values;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Left and right step one card, up and down one row of them — the movements the plain wrap panel's own
|
||||||
|
/// navigation offered, so the board's arrows do what they did, with the card stepped to built on demand.
|
||||||
|
/// </remarks>
|
||||||
|
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
|
||||||
|
{
|
||||||
|
var count = Items.Count;
|
||||||
|
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = from is Control control ? IndexFromContainer(control) : -1;
|
||||||
|
|
||||||
|
var target = direction switch
|
||||||
|
{
|
||||||
|
NavigationDirection.First => 0,
|
||||||
|
NavigationDirection.Last => count - 1,
|
||||||
|
NavigationDirection.Left or NavigationDirection.Previous => current - 1,
|
||||||
|
NavigationDirection.Right or NavigationDirection.Next => current + 1,
|
||||||
|
NavigationDirection.Up => current - columns,
|
||||||
|
NavigationDirection.Down => current + columns,
|
||||||
|
_ => -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (current < 0 && direction is not (NavigationDirection.First or NavigationDirection.Last))
|
||||||
|
{
|
||||||
|
target = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target < 0 || target >= count)
|
||||||
|
{
|
||||||
|
if (!wrap)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
target = (target % count + count) % count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScrollIntoView(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The top of every row, and one more entry past the last row for the total height.</summary>
|
||||||
|
private double[] RowTops(int count)
|
||||||
|
{
|
||||||
|
var rows = (count + columns - 1) / columns;
|
||||||
|
var tops = new double[rows + 1];
|
||||||
|
var estimate = itemSize.Height > 0 ? itemSize.Height : FallbackRowHeight;
|
||||||
|
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
tops[row + 1] = tops[row] + rowHeights.GetValueOrDefault(row, estimate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The first and last item in the rows that overlap the visible part, with the overscan.</summary>
|
||||||
|
private (int First, int Last) VisibleRange(double[] tops, int count)
|
||||||
|
{
|
||||||
|
// Before the first viewport arrives, a screenful from the top: the panel has to measure to something.
|
||||||
|
var top = viewportKnown ? viewport.Top - Overscan : 0;
|
||||||
|
var bottom = viewportKnown ? viewport.Bottom + Overscan : 1200;
|
||||||
|
|
||||||
|
var rows = tops.Length - 1;
|
||||||
|
var firstRow = -1;
|
||||||
|
var lastRow = -1;
|
||||||
|
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
if (tops[row + 1] < top)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tops[row] > bottom)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstRow < 0)
|
||||||
|
{
|
||||||
|
firstRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstRow < 0
|
||||||
|
? (0, -1)
|
||||||
|
: (firstRow * columns, Math.Min(count - 1, ((lastRow + 1) * columns) - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rect ItemRect(int index, double[] tops)
|
||||||
|
{
|
||||||
|
var row = index / columns;
|
||||||
|
var column = index % columns;
|
||||||
|
var height = row + 1 < tops.Length ? tops[row + 1] - tops[row] : itemSize.Height;
|
||||||
|
|
||||||
|
return new Rect(column * itemSize.Width, tops[Math.Min(row, tops.Length - 1)], itemSize.Width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The container for an item, built or brought back from the pool if it is not already up.</summary>
|
||||||
|
private Control RealizedOrNew(int index)
|
||||||
|
{
|
||||||
|
if (realized.TryGetValue(index, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var generator = ItemContainerGenerator!;
|
||||||
|
var item = Items[index];
|
||||||
|
Control container;
|
||||||
|
|
||||||
|
if (generator.NeedsContainer(item, index, out var recycleKey))
|
||||||
|
{
|
||||||
|
var key = recycleKey ?? typeof(object);
|
||||||
|
|
||||||
|
if (recycled.TryGetValue(key, out var pool) && pool.TryPop(out var reused))
|
||||||
|
{
|
||||||
|
container = reused;
|
||||||
|
container.IsVisible = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
container = generator.CreateContainer(item, index, recycleKey);
|
||||||
|
poolOf[container] = key;
|
||||||
|
AddInternalChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
generator.PrepareItemContainer(container, item, index);
|
||||||
|
realized[index] = container;
|
||||||
|
generator.ItemContainerPrepared(container, item, index);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// An item that is its own container: built by whoever made the item, and never pooled.
|
||||||
|
container = (Control)item!;
|
||||||
|
AddInternalChild(container);
|
||||||
|
realized[index] = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts away every built container the predicate does not keep.</summary>
|
||||||
|
private void RecycleAllExcept(Func<int, bool> keep)
|
||||||
|
{
|
||||||
|
foreach (var index in realized.Keys.ToList())
|
||||||
|
{
|
||||||
|
if (keep(index))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = realized[index];
|
||||||
|
realized.Remove(index);
|
||||||
|
|
||||||
|
if (poolOf.TryGetValue(container, out var key))
|
||||||
|
{
|
||||||
|
ItemContainerGenerator!.ClearItemContainer(container);
|
||||||
|
container.IsVisible = false;
|
||||||
|
|
||||||
|
if (!recycled.TryGetValue(key, out var pool))
|
||||||
|
{
|
||||||
|
recycled[key] = pool = new Stack<Control>();
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.Push(container);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RemoveInternalChild(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -297,6 +297,34 @@ public sealed record HostSecret : IVaultSecret
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool RelayEnabled { get; init; }
|
public bool RelayEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How this host is reached through Spelunk, or null for a host that is dialled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A host carrying one is never dialled directly and never falls back to it, for the reason the relay
|
||||||
|
/// does not: the user chose this route because the direct one does not exist or does not work, and a
|
||||||
|
/// connection that quietly went another way would be the box saying one thing and the connection doing
|
||||||
|
/// another. See <see cref="SpelunkRoute"/> for what the route is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b><see cref="Hostname"/> and <see cref="Port"/> still matter, and not for dialling.</b> The host key
|
||||||
|
/// is pinned under them, as it is for every host, so they have to name this machine stably — a host made
|
||||||
|
/// from a Spelunk link takes the server's address, or a name made from its id when Spelunk gave none.
|
||||||
|
/// The port is also what the agent is asked to connect to on the server's own loopback.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Mutually exclusive with <see cref="RelayEnabled"/>.</b> Two routes to one host is the same failure
|
||||||
|
/// as two bindings: the editor, the connect path and the user would each be free to guess which one is
|
||||||
|
/// meant. <see cref="TryValidate"/> refuses the pair.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkRoute? Spelunk { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether Spelunk signs the user in to this host, so it needs no login of its own.</summary>
|
||||||
|
/// <remarks>See <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</remarks>
|
||||||
|
public bool SignsInThroughSpelunk => Spelunk?.SignInThroughSpelunk is true;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the fields that must hold before this can be stored.
|
/// Checks the fields that must hold before this can be stored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -332,7 +360,8 @@ public sealed record HostSecret : IVaultSecret
|
|||||||
|
|
||||||
return PinnedPathsAreStorable(out reason)
|
return PinnedPathsAreStorable(out reason)
|
||||||
&& PortForwardsAreStorable(out reason)
|
&& PortForwardsAreStorable(out reason)
|
||||||
&& ReferencesAreStorable(out reason);
|
&& ReferencesAreStorable(out reason)
|
||||||
|
&& RouteIsStorable(out reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
|
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
|
||||||
@@ -356,6 +385,24 @@ public sealed record HostSecret : IVaultSecret
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks the Spelunk route, if there is one, and that it is the only route.</summary>
|
||||||
|
private bool RouteIsStorable([NotNullWhen(false)] out string? reason)
|
||||||
|
{
|
||||||
|
if (Spelunk is null)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RelayEnabled)
|
||||||
|
{
|
||||||
|
reason = "A host is reached through the server relay or through Spelunk, not both.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Spelunk.TryValidate(out reason);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the paths pinned on this host.
|
/// Checks the paths pinned on this host.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -119,12 +119,44 @@ public static class HostSecretCodec
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int PortForwardsSchemaVersion = 8;
|
public const int PortForwardsSchemaVersion = 8;
|
||||||
|
|
||||||
|
/// <summary>The version that introduced <see cref="HostSecret.Spelunk"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Its own version, by the rule this file follows, and it matters more here than for a tag. An older
|
||||||
|
/// build that decoded a Spelunk host as editable would drop the route on the next save — and the host
|
||||||
|
/// would then be dialled directly, at an address that exists only so its key has a name to be pinned
|
||||||
|
/// under. Written at this version, the older build shows the host, refuses to edit it, and says to update.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Additive, like every version but <see cref="PortInheritSchemaVersion"/>: the route is one more
|
||||||
|
/// property, and a build that does not know it skips it and still reads a valid host. It follows
|
||||||
|
/// <see cref="PortForwardsSchemaVersion"/>, whose forwards reached main first.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public const int SpelunkSchemaVersion = 9;
|
||||||
|
|
||||||
|
/// <summary>The version that introduced <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Its own version, and nested in the existing <c>spelunk</c> object rather than beside it, because it
|
||||||
|
/// is part of that one decision. A build that knows 9 skips the unknown member and reads a valid host —
|
||||||
|
/// a Spelunk host with no login of its own — and it must not save that back: the flag would be dropped
|
||||||
|
/// and the host would start asking for a password it never had. Written at this version, that build
|
||||||
|
/// shows the host, refuses to edit it, and says to update.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only a route that signs in is written here. Every other Spelunk host stays at
|
||||||
|
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public const int SpelunkSignInSchemaVersion = 10;
|
||||||
|
|
||||||
/// <summary>The highest schema version this build can write.</summary>
|
/// <summary>The highest schema version this build can write.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||||
/// maximum. A new field added below this line has to be named here too.
|
/// maximum. A new field added below this line has to be named here too.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int CurrentSchemaVersion = PortForwardsSchemaVersion;
|
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
|
||||||
|
|
||||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||||
@@ -175,6 +207,9 @@ public static class HostSecretCodec
|
|||||||
PortForwards = host.PortForwards.Count == 0
|
PortForwards = host.PortForwards.Count == 0
|
||||||
? null
|
? null
|
||||||
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
||||||
|
// Last of all, and absent for a host that is dialled, so every host without a route encodes
|
||||||
|
// exactly as it did before routes existed.
|
||||||
|
Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return JsonSerializer.SerializeToUtf8Bytes(
|
return JsonSerializer.SerializeToUtf8Bytes(
|
||||||
@@ -255,6 +290,16 @@ public static class HostSecretCodec
|
|||||||
version = Math.Max(version, PortForwardsSchemaVersion);
|
version = Math.Max(version, PortForwardsSchemaVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (host.Spelunk is not null)
|
||||||
|
{
|
||||||
|
version = Math.Max(version, SpelunkSchemaVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.SignsInThroughSpelunk)
|
||||||
|
{
|
||||||
|
version = Math.Max(version, SpelunkSignInSchemaVersion);
|
||||||
|
}
|
||||||
|
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +385,7 @@ public static class HostSecretCodec
|
|||||||
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
|
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
|
||||||
|
|
||||||
PortForwards = forwards,
|
PortForwards = forwards,
|
||||||
|
Spelunk = RouteFrom(parsed.Spelunk),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!candidate.TryValidate(out _))
|
if (!candidate.TryValidate(out _))
|
||||||
@@ -389,6 +435,26 @@ public static class HostSecretCodec
|
|||||||
forwards = PortForwardList.Create(rules);
|
forwards = PortForwardList.Create(rules);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The route as stored, or null when there is none.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Missing parts come through as empty values rather than making the route vanish, so that
|
||||||
|
/// <see cref="HostSecret.TryValidate"/> refuses the host. Reading a half-written route as "no route"
|
||||||
|
/// would turn a Spelunk host into one that is dialled directly, which is the one reading of a damaged
|
||||||
|
/// payload that does something the user never asked for.
|
||||||
|
/// </remarks>
|
||||||
|
private static SpelunkRoute? RouteFrom(SpelunkPayloadDocument? parsed) =>
|
||||||
|
parsed is null
|
||||||
|
? null
|
||||||
|
: new SpelunkRoute
|
||||||
|
{
|
||||||
|
ApiBase = parsed.Api ?? string.Empty,
|
||||||
|
ServerId = parsed.ServerId ?? Guid.Empty,
|
||||||
|
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
|
||||||
|
|
||||||
|
// False folds to the default, as AsksForPassword's does: one state, one spelling.
|
||||||
|
SignInThroughSpelunk = parsed.SignIn is true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -462,6 +528,40 @@ internal sealed class HostPayloadDocument
|
|||||||
|
|
||||||
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
|
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
|
||||||
public PortForwardDocument?[]? PortForwards { get; set; }
|
public PortForwardDocument?[]? PortForwards { get; set; }
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Last, and null for a host that is dialled, for the reason <see cref="TagIds"/> gives. One nested object
|
||||||
|
/// rather than three loose properties, because the three are one decision — a server id without its
|
||||||
|
/// Spelunk means nothing — and an object makes "all or none" the shape of the JSON.
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkPayloadDocument? Spelunk { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The serialised shape of a <see cref="SpelunkRoute"/>. Nullable because it models untrusted input.</summary>
|
||||||
|
internal sealed class SpelunkPayloadDocument
|
||||||
|
{
|
||||||
|
public string? Api { get; set; }
|
||||||
|
|
||||||
|
public Guid? ServerId { get; set; }
|
||||||
|
|
||||||
|
public Guid? ApiKeyCredentialId { get; set; }
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
|
||||||
|
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public bool? SignIn { get; set; }
|
||||||
|
|
||||||
|
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
|
||||||
|
{
|
||||||
|
Api = route.ApiBase,
|
||||||
|
ServerId = route.ServerId,
|
||||||
|
ApiKeyCredentialId = route.ApiKeyCredentialId,
|
||||||
|
|
||||||
|
// Null rather than false, so a route that does not sign in encodes exactly as it did before the
|
||||||
|
// field existed — the rule AsksForPassword follows.
|
||||||
|
SignIn = route.SignInThroughSpelunk ? true : null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
|
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
|
||||||
|
|||||||
@@ -168,6 +168,18 @@ public static class HostSecretMerge
|
|||||||
remote.AsksForPassword,
|
remote.AsksForPassword,
|
||||||
conflicts,
|
conflicts,
|
||||||
static asked => asked is true ? "a typed password" : "the group's binding"),
|
static asked => asked is true ? "a typed password" : "the group's binding"),
|
||||||
|
|
||||||
|
// Here rather than beside RelayEnabled, because it is a reference too — to a Spelunk server and to
|
||||||
|
// the credential holding its key — and it merges as one value for the reason the jump chain does:
|
||||||
|
// an API address from one side and a server id from the other would be a route neither user
|
||||||
|
// chose, to a server that may not exist on that Spelunk.
|
||||||
|
Spelunk = Field(
|
||||||
|
nameof(HostSecret.Spelunk),
|
||||||
|
ancestor.Spelunk,
|
||||||
|
local.Spelunk,
|
||||||
|
remote.Spelunk,
|
||||||
|
conflicts,
|
||||||
|
static route => route is null ? "dialled directly" : $"server {route.ServerId} on {route.ApiBase}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string Text(
|
private static string Text(
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A Spelunk API key and the one Spelunk it may be sent to, decrypted.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The address is what makes this an item of its own.</b> A key kept as a credential's password can be
|
||||||
|
/// sent anywhere a host names, and a link from Spelunk can be sent by anything on the machine — so a link
|
||||||
|
/// that is not already backed by a saved host has to stop and ask. A key stored with its address says, once
|
||||||
|
/// and in the keychain, where the user agreed it may go: a link from that Spelunk connects straight away, and
|
||||||
|
/// a host naming this key with any other address is refused at connect rather than sending it there. See
|
||||||
|
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b><see cref="ApiKey"/> is a password, and everything this codebase does about passwords applies to
|
||||||
|
/// it</b>, as it does to <see cref="ObjectStoreSecret.SecretAccessKey"/>: it is inside the encrypted payload,
|
||||||
|
/// the activity log records that it changed and never what it was, and the merge reports that it differed
|
||||||
|
/// rather than either value.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SpelunkKeySecret : IVaultSecret
|
||||||
|
{
|
||||||
|
/// <summary>The longest API key stored.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Far beyond any real key, for the reason <see cref="SpelunkRoute.MaxApiBaseLength"/> exists: an
|
||||||
|
/// unbounded string is the shape a corrupted payload takes, and this one goes into a request header.
|
||||||
|
/// </remarks>
|
||||||
|
public const int MaxApiKeyLength = 4096;
|
||||||
|
|
||||||
|
/// <summary>What the user calls this key.</summary>
|
||||||
|
public required string Label { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Spelunk API this key is for, as <see cref="SpelunkRoute.TryNormalizeApiBase"/> writes it, and the
|
||||||
|
/// only address it is ever sent to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Normalised for the reason <see cref="SpelunkRoute.ApiBase"/> is: it is compared, byte for byte, with
|
||||||
|
/// the address a host or a link names.
|
||||||
|
/// </remarks>
|
||||||
|
public required string ApiBase { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The API key itself, as Spelunk issued it.</summary>
|
||||||
|
public required string ApiKey { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether this is storable, and why not if it is not.</summary>
|
||||||
|
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(Label))
|
||||||
|
{
|
||||||
|
reason = "A Spelunk key needs a name.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ApiBase))
|
||||||
|
{
|
||||||
|
reason = "A Spelunk key needs the address of the Spelunk it is for.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(ApiBase, out var normalized, out reason))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
reason = "The Spelunk API address is not in its normal form.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(ApiKey))
|
||||||
|
{
|
||||||
|
reason = "A Spelunk key needs the key.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A header value cannot hold a line break, and a key with one would fail at the first connect with an
|
||||||
|
// HTTP error that names neither the key nor this item.
|
||||||
|
if (ApiKey.Length > MaxApiKeyLength || ApiKey.Any(char.IsControl))
|
||||||
|
{
|
||||||
|
reason = "That does not look like a Spelunk API key.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>A decoded Spelunk key payload, together with the schema version it was written at.</summary>
|
||||||
|
/// <param name="Key">The key.</param>
|
||||||
|
/// <param name="SchemaVersion">The version the writing client used.</param>
|
||||||
|
public sealed record SpelunkKeySecretDocument(SpelunkKeySecret Key, int SchemaVersion)
|
||||||
|
{
|
||||||
|
/// <inheritdoc cref="HostSecretDocument.IsReadOnly" />
|
||||||
|
public bool IsReadOnly => SchemaVersion > SpelunkKeySecretCodec.CurrentSchemaVersion;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes and decodes the plaintext inside a Spelunk key item's encrypted payload.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Mirrors <see cref="ObjectStoreSecretCodec"/>, for the same reasons and with the same guarantees.
|
||||||
|
/// </remarks>
|
||||||
|
public static class SpelunkKeySecretCodec
|
||||||
|
{
|
||||||
|
/// <summary>The schema version this build writes.</summary>
|
||||||
|
public const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
|
/// <summary>Serialises a key to the bytes that get sealed.</summary>
|
||||||
|
/// <exception cref="ArgumentException">The key is not valid for storage.</exception>
|
||||||
|
public static byte[] Encode(SpelunkKeySecret key)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(key);
|
||||||
|
|
||||||
|
if (!key.TryValidate(out var reason))
|
||||||
|
{
|
||||||
|
throw new ArgumentException(reason, nameof(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
var document = new SpelunkKeyPayloadDocument
|
||||||
|
{
|
||||||
|
SchemaVersion = CurrentSchemaVersion,
|
||||||
|
Label = key.Label,
|
||||||
|
ApiBase = key.ApiBase,
|
||||||
|
ApiKey = key.ApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
return JsonSerializer.SerializeToUtf8Bytes(
|
||||||
|
document, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parses a decrypted payload.</summary>
|
||||||
|
/// <inheritdoc cref="HostSecretCodec.TryDecode" path="/remarks" />
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> payload,
|
||||||
|
[NotNullWhen(true)] out SpelunkKeySecretDocument? document)
|
||||||
|
{
|
||||||
|
document = null;
|
||||||
|
|
||||||
|
SpelunkKeyPayloadDocument? parsed;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
parsed = JsonSerializer.Deserialize(
|
||||||
|
payload, SpelunkKeyPayloadJsonContext.Default.SpelunkKeyPayloadDocument);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed is null || parsed.SchemaVersion < 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = new SpelunkKeySecret
|
||||||
|
{
|
||||||
|
Label = parsed.Label ?? string.Empty,
|
||||||
|
ApiBase = parsed.ApiBase ?? string.Empty,
|
||||||
|
ApiKey = parsed.ApiKey ?? string.Empty,
|
||||||
|
};
|
||||||
|
|
||||||
|
// A key whose address is not in its normal form is refused rather than normalised here: the address
|
||||||
|
// is what decides where the key may go, and a payload that disagrees with how every client writes it
|
||||||
|
// is not one to guess about.
|
||||||
|
if (!candidate.TryValidate(out _))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
document = new SpelunkKeySecretDocument(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 SpelunkKeyPayloadDocument
|
||||||
|
{
|
||||||
|
public int SchemaVersion { get; set; }
|
||||||
|
|
||||||
|
public string? Label { get; set; }
|
||||||
|
|
||||||
|
public string? ApiBase { get; set; }
|
||||||
|
|
||||||
|
public string? ApiKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
|
[JsonSerializable(typeof(SpelunkKeyPayloadDocument))]
|
||||||
|
internal sealed partial class SpelunkKeyPayloadJsonContext : JsonSerializerContext;
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>The merged Spelunk key, and everything that had to be overridden to produce it.</summary>
|
||||||
|
/// <param name="Merged">The key to store and push.</param>
|
||||||
|
/// <param name="Conflicts">Empty when the two sides were reconcilable field by field.</param>
|
||||||
|
public sealed record SpelunkKeyMergeResult(
|
||||||
|
SpelunkKeySecret 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 Spelunk key against the version they both started from.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The address and the key merge as one value, not two.</b> The pair is the whole meaning of this item —
|
||||||
|
/// "this key may go to this Spelunk" — and merging them apart could keep one device's new address beside the
|
||||||
|
/// other device's new key, sending a key to a Spelunk nobody ever paired it with. So when both sides changed
|
||||||
|
/// the pair, one side's pair is kept whole.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The key never reaches the conflict log</b>, for the reason a secret access key does not: a discarded
|
||||||
|
/// one is very often still live. The addresses are shown, because which Spelunk the kept key goes to is the
|
||||||
|
/// whole content of the notice.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class SpelunkKeySecretMerge
|
||||||
|
{
|
||||||
|
/// <summary>The name a conflict over the address and key is recorded under.</summary>
|
||||||
|
public const string PairField = "ApiBaseAndKey";
|
||||||
|
|
||||||
|
/// <summary>Produces the merged key.</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 SpelunkKeyMergeResult Merge(
|
||||||
|
SpelunkKeySecret ancestor,
|
||||||
|
SpelunkKeySecret local,
|
||||||
|
SpelunkKeySecret remote)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(ancestor);
|
||||||
|
ArgumentNullException.ThrowIfNull(local);
|
||||||
|
ArgumentNullException.ThrowIfNull(remote);
|
||||||
|
|
||||||
|
var conflicts = new List<HostFieldConflict>();
|
||||||
|
|
||||||
|
var label = ThreeWayMerge.Scalar(ancestor.Label, local.Label, remote.Label, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
if (label.IsConflicted)
|
||||||
|
{
|
||||||
|
conflicts.Add(new HostFieldConflict(
|
||||||
|
nameof(SpelunkKeySecret.Label),
|
||||||
|
MergeSide.Local,
|
||||||
|
label.Value,
|
||||||
|
label.Discarded ?? "(none)",
|
||||||
|
DiscardedWasRemoval: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tuple of two strings compares both ordinally, which is the comparison each field needs.
|
||||||
|
var pair = ThreeWayMerge.Scalar(
|
||||||
|
(ancestor.ApiBase, ancestor.ApiKey),
|
||||||
|
(local.ApiBase, local.ApiKey),
|
||||||
|
(remote.ApiBase, remote.ApiKey));
|
||||||
|
|
||||||
|
if (pair.IsConflicted)
|
||||||
|
{
|
||||||
|
conflicts.Add(new HostFieldConflict(
|
||||||
|
PairField,
|
||||||
|
MergeSide.Local,
|
||||||
|
$"{pair.Value.ApiBase} (with its key)",
|
||||||
|
string.Equals(pair.Discarded.ApiBase, pair.Value.ApiBase, StringComparison.Ordinal)
|
||||||
|
? "(a different key for the same address was discarded)"
|
||||||
|
: $"{pair.Discarded.ApiBase} (with its key)",
|
||||||
|
DiscardedWasRemoval: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged = new SpelunkKeySecret
|
||||||
|
{
|
||||||
|
Label = label.Value,
|
||||||
|
ApiBase = pair.Value.ApiBase,
|
||||||
|
ApiKey = pair.Value.ApiKey,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new SpelunkKeyMergeResult(merged, conflicts);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Spelunk manages servers through an agent that runs on each of them and keeps a connection open to
|
||||||
|
/// Spelunk. A host carrying one of these is never dialled: a connection to it asks that Spelunk for a
|
||||||
|
/// ticket, opens a WebSocket, and Spelunk hands the bytes to the agent, which connects to the SSH server on
|
||||||
|
/// the machine's own loopback. That reaches a server with no public SSH port at all — which is the case
|
||||||
|
/// this exists for — and it is why nothing here names an address to dial.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Inside the host's encrypted payload, and nowhere else.</b> Unlike the relay, nothing about this has to
|
||||||
|
/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, with a key only
|
||||||
|
/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names the keychain item
|
||||||
|
/// holding it, for the reason <see cref="HostSecret.CredentialId"/> gives: one Spelunk key is the key for
|
||||||
|
/// every server in its organisation, and a copy per host is twenty places to rotate it and one to forget.
|
||||||
|
/// That item is a <see cref="SpelunkKeySecret"/>, which also says the one address the key may go to, or —
|
||||||
|
/// for a host set up before those existed — an ordinary credential whose password is the key.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SpelunkRoute
|
||||||
|
{
|
||||||
|
/// <summary>The longest API address stored.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Far beyond any real address, and there for the reason <see cref="HostSecret.MaxPinnedPathLength"/>
|
||||||
|
/// is: this is rendered by every device that decrypts the host, and an unbounded string is the shape a
|
||||||
|
/// corrupted payload takes.
|
||||||
|
/// </remarks>
|
||||||
|
public const int MaxApiBaseLength = 512;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Spelunk API's base address, as <see cref="TryNormalizeApiBase"/> writes it — for example
|
||||||
|
/// <c>https://api.spelunk.example.com</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A string in its normalised form rather than a <see cref="Uri"/>, because this is compared: two hosts
|
||||||
|
/// on the same Spelunk must say so in the same bytes, or a link from Spelunk would not find the host
|
||||||
|
/// already saved for it and the merge would see a change nobody made.
|
||||||
|
/// </remarks>
|
||||||
|
public required string ApiBase { get; init; }
|
||||||
|
|
||||||
|
/// <summary>The server's id in Spelunk.</summary>
|
||||||
|
public required Guid ServerId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The keychain item holding the Spelunk API key: a <see cref="SpelunkKeySecret"/>, or a credential whose
|
||||||
|
/// password is the key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Named for the credential it always was, and kept so on the wire: item ids are unique across kinds, so
|
||||||
|
/// the one id says which, and a build that predates Spelunk keys reads a host naming one as a host whose
|
||||||
|
/// key credential has gone — refused at connect, and kept as it is by an edit — rather than as damage.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Required rather than nullable. A route with no key can never be opened, and a host saved that way
|
||||||
|
/// would refuse every connection with a sentence about something the editor let through. The reference
|
||||||
|
/// can still dangle — the credential may be deleted on another machine — and that is refused where it
|
||||||
|
/// is noticed, at connect, exactly as a missing SSH credential is.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public required Guid ApiKeyCredentialId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For each connection this client makes a throwaway key pair in memory and sends the public half with
|
||||||
|
/// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on
|
||||||
|
/// the machine whose only authorised key is that one, and says which account it logs in as. It is real
|
||||||
|
/// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host,
|
||||||
|
/// and the key is gone when the connection is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What the host says about logging in is then not asked.</b> Its username, key, credential and
|
||||||
|
/// typed-password choice — its own or a group's — are left alone rather than refused, because they are
|
||||||
|
/// the host's answer for the day this is unticked, and refusing a host that carries them would make a
|
||||||
|
/// merge of two honest edits undecodable. The editor stores no binding beside this, and the connect path
|
||||||
|
/// never reads one for a host that has it. See <c>VaultViewModel.TryBuildAuthentication</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Written from <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>, and only when true, so a route
|
||||||
|
/// without it encodes exactly as it did before it existed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public bool SignInThroughSpelunk { get; init; }
|
||||||
|
|
||||||
|
/// <summary>Whether this is the route to the given server on the given Spelunk.</summary>
|
||||||
|
/// <param name="apiBase">A normalised API base, as <see cref="TryNormalizeApiBase"/> writes it.</param>
|
||||||
|
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||||
|
public bool IsFor(string apiBase, Guid serverId) =>
|
||||||
|
ServerId == serverId && string.Equals(ApiBase, apiBase, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Checks that this route can be stored and opened.</summary>
|
||||||
|
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||||
|
{
|
||||||
|
if (!TryNormalizeApiBase(ApiBase, out var normalized, out reason))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stored normalised or not at all, for the comparison the remarks on ApiBase depend on.
|
||||||
|
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
reason = "The Spelunk API address is not in its normal form.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ServerId == Guid.Empty)
|
||||||
|
{
|
||||||
|
reason = "A host reached through Spelunk needs the id of its Spelunk server.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ApiKeyCredentialId == Guid.Empty)
|
||||||
|
{
|
||||||
|
reason = "A host reached through Spelunk needs its Spelunk API key chosen.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a Spelunk API address as a user or a link gives it, into the one form it is stored in.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>HTTPS, or plain HTTP to this machine only.</b> The API key travels in a header on every tunnel, so
|
||||||
|
/// an address that sends it in clear across a network is refused rather than stored. A Spelunk running
|
||||||
|
/// on the developer's own box is the one place plain HTTP is harmless, and it is allowed there so the
|
||||||
|
/// feature can be tried against one.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// No user name, password, query or fragment: none of them means anything to the API, and the first two
|
||||||
|
/// are the classic way to make an address read as one host while naming another. What is left is the
|
||||||
|
/// scheme, the authority and any path prefix, lower-cased where URLs are case-insensitive and without a
|
||||||
|
/// trailing slash — so the same Spelunk typed twice is stored the same way twice.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static bool TryNormalizeApiBase(
|
||||||
|
string? text,
|
||||||
|
[NotNullWhen(true)] out string? apiBase,
|
||||||
|
[NotNullWhen(false)] out string? reason)
|
||||||
|
{
|
||||||
|
apiBase = null;
|
||||||
|
|
||||||
|
var trimmed = text?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (trimmed.Length == 0)
|
||||||
|
{
|
||||||
|
reason = "A host reached through Spelunk needs the address of the Spelunk API.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.Length > MaxApiBaseLength
|
||||||
|
|| !Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||||
|
|| uri.Scheme is not ("https" or "http")
|
||||||
|
|| uri.HostNameType is UriHostNameType.Unknown
|
||||||
|
|| string.IsNullOrEmpty(uri.Host))
|
||||||
|
{
|
||||||
|
reason = "The Spelunk API address must be a web address, like https://api.spelunk.example.com.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback)
|
||||||
|
{
|
||||||
|
reason = "The Spelunk API address must use https, because the API key is sent with every connection.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0)
|
||||||
|
{
|
||||||
|
reason = "The Spelunk API address cannot carry a user name, a query or a fragment.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
apiBase = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,6 +283,9 @@ internal sealed class S3FileStore : IRemoteFileStore
|
|||||||
/// have cost.
|
/// have cost.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CanWriteFromAnOffset => false;
|
||||||
|
|
||||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(path);
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ public sealed record ClientPaths(string DataDirectory)
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
||||||
|
|
||||||
|
/// <summary>Where the application writes down why it died, when something nothing caught ends it.</summary>
|
||||||
|
public string CrashLogFile => Path.Combine(DataDirectory, "crash.log");
|
||||||
|
|
||||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A way to a host that is not a dial: one connection long, handed to SSH.NET as a loopback proxy.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Two things answer to this — the server relay (<see cref="RelayRoute"/>) and a Spelunk tunnel
|
||||||
|
/// (<see cref="SpelunkTunnelRoute"/>) — and both are the same shape underneath: a WebSocket carrying the
|
||||||
|
/// SSH bytes, and a <see cref="RelayBridge"/> that turns it into an address SSH.NET can be given. What the
|
||||||
|
/// connect path needs from either is exactly that address, the reason the far end gave if it hung up, and a
|
||||||
|
/// way to let go of it when the connect never got as far as handing it over.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Handing <see cref="Proxy"/> to a connection hands the bridge over with it, so the connection's own
|
||||||
|
/// disposal is what closes the route. <see cref="IAsyncDisposable.DisposeAsync"/> is for a caller that ends
|
||||||
|
/// up not connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public interface IConnectionRoute : IAsyncDisposable
|
||||||
|
{
|
||||||
|
/// <summary>The loopback proxy to connect through, which owns the route from then on.</summary>
|
||||||
|
SshLoopbackProxy Proxy { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What the far end said when it closed, in words a user can act on; null when it has not closed, or
|
||||||
|
/// closed with nothing to say.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For a connect that failed. SSH.NET reports a route that hung up as a stream that ended, which reads
|
||||||
|
/// exactly like the direct connection the route was chosen to get around — so the route's own reason,
|
||||||
|
/// when it gave one, replaces it.
|
||||||
|
/// </remarks>
|
||||||
|
string? Failure { get; }
|
||||||
|
}
|
||||||
@@ -40,7 +40,7 @@ public interface IServerRelay
|
|||||||
/// disposal is what closes the relay. <see cref="DisposeAsync"/> is for a caller that ends up not
|
/// disposal is what closes the relay. <see cref="DisposeAsync"/> is for a caller that ends up not
|
||||||
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class RelayRoute : IAsyncDisposable
|
public sealed class RelayRoute : IConnectionRoute
|
||||||
{
|
{
|
||||||
private readonly RelayBridge bridge;
|
private readonly RelayBridge bridge;
|
||||||
|
|
||||||
@@ -80,6 +80,9 @@ public sealed class RelayRoute : IAsyncDisposable
|
|||||||
RelayProtocol.DurationLimitCloseCode =>
|
RelayProtocol.DurationLimitCloseCode =>
|
||||||
"The server relay closed the connection: it reached the longest session the server allows.",
|
"The server relay closed the connection: it reached the longest session the server allows.",
|
||||||
|
|
||||||
|
RelayProtocol.AccessRevokedCloseCode =>
|
||||||
|
"The server relay closed the connection: you no longer have access to this host's vault.",
|
||||||
|
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <c>dodossh://spelunk/connect</c> link from Spelunk's web interface, read and checked.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
|
||||||
|
/// <c>dodossh://spelunk/connect?api=…&server=…&name=…&host=…</c>. It says which Spelunk and which
|
||||||
|
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: the
|
||||||
|
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
|
||||||
|
/// supply somebody else's.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
|
||||||
|
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
|
||||||
|
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
|
||||||
|
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
|
||||||
|
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
|
||||||
|
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||||
|
/// <param name="ServerId">The server's id in Spelunk.</param>
|
||||||
|
/// <param name="Name">What Spelunk calls the server, cleaned for display.</param>
|
||||||
|
/// <param name="Host">The server's public address as Spelunk knows it, or null when it gave none.</param>
|
||||||
|
public sealed record SpelunkLink(string ApiBase, Guid ServerId, string Name, string? Host)
|
||||||
|
{
|
||||||
|
/// <summary>The URL scheme DodoSSH registers.</summary>
|
||||||
|
public const string Scheme = "dodossh";
|
||||||
|
|
||||||
|
/// <summary>The longest link read at all. Anything longer is refused before it is parsed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A real link is a few hundred characters. The bound is what a second launch may put down the doorbell
|
||||||
|
/// pipe, and what the parser is asked to walk — neither of which should be up to the sender.
|
||||||
|
/// </remarks>
|
||||||
|
public const int MaxLength = 4096;
|
||||||
|
|
||||||
|
/// <summary>The longest name kept. Longer is cut, not refused: it is only a label.</summary>
|
||||||
|
public const int MaxNameLength = 100;
|
||||||
|
|
||||||
|
private const string Authority = "spelunk";
|
||||||
|
private const string Path = "/connect";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The hostname a new host from this link is given: Spelunk's address for the server, or a name made
|
||||||
|
/// from its id when there is none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// It is not dialled — the tunnel ignores it — but the host key is pinned under it, so it has to be
|
||||||
|
/// stable for this server and unlike any other. The id is both; the address is also what the user would
|
||||||
|
/// recognise, and a key already trusted for a direct connection to that address is found again.
|
||||||
|
/// </remarks>
|
||||||
|
public string Hostname => Host ?? $"spelunk-{ServerId}";
|
||||||
|
|
||||||
|
/// <summary>Whether a string looks like it is meant to be one of these, before checking it is one.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For the launcher, which is handed arguments of every kind and needs to tell a link that failed the
|
||||||
|
/// checks from something that was never a link.
|
||||||
|
/// </remarks>
|
||||||
|
public static bool LooksLikeOne(string? text) =>
|
||||||
|
text is not null && text.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Reads a link, or refuses it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Strict on everything that decides where anything goes: the scheme, <c>spelunk</c> as the authority
|
||||||
|
/// with no user, password or port, <c>/connect</c> as the path, no fragment, each known parameter at
|
||||||
|
/// most once, an API address <see cref="SpelunkRoute.TryNormalizeApiBase"/> accepts, and a server id
|
||||||
|
/// that is a real one. Unknown parameters are ignored rather than refused, so a newer Spelunk can add
|
||||||
|
/// one without every existing DodoSSH ignoring its links outright.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Lenient only on the name, which is display text: control characters become spaces and a long one is
|
||||||
|
/// cut. A missing name falls back to the host, then to a plain one.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static bool TryParse(string? text, [NotNullWhen(true)] out SpelunkLink? link)
|
||||||
|
{
|
||||||
|
link = null;
|
||||||
|
|
||||||
|
if (text is null
|
||||||
|
|| text.Length > MaxLength
|
||||||
|
|| text.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|
||||||
|
|| !Uri.TryCreate(text, UriKind.Absolute, out var uri)
|
||||||
|
|| !string.Equals(uri.Scheme, Scheme, StringComparison.Ordinal)
|
||||||
|
|| !string.Equals(uri.Host, Authority, StringComparison.Ordinal)
|
||||||
|
|| uri.UserInfo.Length > 0
|
||||||
|
|| !uri.IsDefaultPort
|
||||||
|
|| !string.Equals(uri.AbsolutePath, Path, StringComparison.Ordinal)
|
||||||
|
|| uri.Fragment.Length > 0
|
||||||
|
|| !TryReadQuery(QueryOf(text), out var parameters)
|
||||||
|
|| !parameters.TryGetValue("api", out var api)
|
||||||
|
|| !SpelunkRoute.TryNormalizeApiBase(api, out var apiBase, out _)
|
||||||
|
|| !parameters.TryGetValue("server", out var server)
|
||||||
|
|| !Guid.TryParseExact(server, "D", out var serverId)
|
||||||
|
|| serverId == Guid.Empty)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string? host = null;
|
||||||
|
|
||||||
|
if (parameters.TryGetValue("host", out var given) && given.Trim() is { Length: > 0 } trimmed)
|
||||||
|
{
|
||||||
|
if (trimmed.Length > 253 || Uri.CheckHostName(trimmed) is UriHostNameType.Unknown)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
host = trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = Clean(parameters.GetValueOrDefault("name")) ?? host ?? "Spelunk server";
|
||||||
|
|
||||||
|
link = new SpelunkLink(apiBase, serverId, name, host);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The link written out again, in the one form <see cref="TryParse"/> reads back to the same value.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What a second launch sends the running instance, so the instance parses a link this build wrote rather
|
||||||
|
/// than whatever the browser handed over.
|
||||||
|
/// </remarks>
|
||||||
|
public override string ToString()
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder($"{Scheme}://{Authority}{Path}?api=")
|
||||||
|
.Append(Uri.EscapeDataString(ApiBase))
|
||||||
|
.Append("&server=")
|
||||||
|
.Append(ServerId.ToString("D"))
|
||||||
|
.Append("&name=")
|
||||||
|
.Append(Uri.EscapeDataString(Name));
|
||||||
|
|
||||||
|
if (Host is not null)
|
||||||
|
{
|
||||||
|
builder.Append("&host=").Append(Uri.EscapeDataString(Host));
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Splits a query into its known parameters, refusing one that names any of them twice.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// By hand rather than through a web utility, because this project has none and because the rule that
|
||||||
|
/// matters — twice is refused, not "last wins" — is one no general-purpose parser applies. <c>+</c> is a
|
||||||
|
/// space, as a browser's form encoding writes it.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryReadQuery(string query, out Dictionary<string, string> parameters)
|
||||||
|
{
|
||||||
|
parameters = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
var equals = pair.IndexOf('=', StringComparison.Ordinal);
|
||||||
|
var key = equals < 0 ? pair : pair[..equals];
|
||||||
|
var value = equals < 0 ? string.Empty : pair[(equals + 1)..];
|
||||||
|
|
||||||
|
if (key is not ("api" or "server" or "name" or "host"))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
string decoded;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
decoded = Uri.UnescapeDataString(value.Replace('+', ' '));
|
||||||
|
}
|
||||||
|
catch (UriFormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parameters.TryAdd(key, decoded))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The query exactly as it was written, rather than as <see cref="Uri"/> re-escapes it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="Uri"/> has no rules for a scheme it does not know, and how much of the query it unescapes
|
||||||
|
/// for one is its business. Taking the text after the first <c>?</c> means every parameter is decoded
|
||||||
|
/// exactly once, here.
|
||||||
|
/// </remarks>
|
||||||
|
private static string QueryOf(string text) =>
|
||||||
|
text.IndexOf('?', StringComparison.Ordinal) is var at and >= 0 ? text[(at + 1)..] : string.Empty;
|
||||||
|
|
||||||
|
private static string? Clean(string? name)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(name))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cleaned = new string([.. name.Trim().Select(character => char.IsControl(character) ? ' ' : character)]);
|
||||||
|
|
||||||
|
return cleaned.Length > MaxNameLength ? cleaned[..MaxNameLength].TrimEnd() : cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The throwaway key one connection signs in to a Spelunk server with.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For a host set to sign in through Spelunk — see <c>SpelunkRoute.SignInThroughSpelunk</c> — each connection
|
||||||
|
/// makes one of these, sends <see cref="PublicKeyLine"/> with its ticket request, and authenticates with
|
||||||
|
/// <see cref="Credential"/> once the tunnel is up. Spelunk's agent runs a one-connection SSH server whose only
|
||||||
|
/// authorised key is this one, so the key is worth nothing after the connection it was made for.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Made in memory and never kept.</b> Nothing writes it to a disk or to the vault, and nothing logs it:
|
||||||
|
/// <see cref="ToString"/> says what it is and nothing of what it holds. Ed25519, which is what
|
||||||
|
/// <see cref="SshKeyGenerator"/> makes instantly and what every OpenSSH the agent could start accepts.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SpelunkSignIn
|
||||||
|
{
|
||||||
|
/// <summary>The account Spelunk signs in as when its answer does not name one.</summary>
|
||||||
|
public const string DefaultUser = "root";
|
||||||
|
|
||||||
|
/// <summary>The comment the public key carries, which is what Spelunk and the server's logs see.</summary>
|
||||||
|
internal const string KeyComment = "dodossh-spelunk-sign-in";
|
||||||
|
|
||||||
|
private SpelunkSignIn(string publicKeyLine, SshPrivateKeyCredential credential)
|
||||||
|
{
|
||||||
|
PublicKeyLine = publicKeyLine;
|
||||||
|
Credential = credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The public half, as one OpenSSH <c>authorized_keys</c> line: <c>ssh-ed25519 AAAA… comment</c>.</summary>
|
||||||
|
public string PublicKeyLine { get; }
|
||||||
|
|
||||||
|
/// <summary>The private half, in the form a connection authenticates with.</summary>
|
||||||
|
public SshPrivateKeyCredential Credential { get; }
|
||||||
|
|
||||||
|
/// <summary>Makes a fresh key pair for one connection.</summary>
|
||||||
|
public static SpelunkSignIn Create()
|
||||||
|
{
|
||||||
|
var key = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, KeyComment);
|
||||||
|
|
||||||
|
return new SpelunkSignIn(
|
||||||
|
key.PublicKeyLine.Trim(),
|
||||||
|
new SshPrivateKeyCredential(Encoding.UTF8.GetBytes(key.PrivateKeyArmour), Passphrase: null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>Says nothing of the key, so one that reaches a log by accident takes nothing with it.</remarks>
|
||||||
|
public override string ToString() => "Spelunk sign-in key";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The credential of a connection that will sign in through Spelunk, before the tunnel has made its key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A host that signs in through Spelunk is resolved to a login before anything is opened — the same
|
||||||
|
/// resolution every host goes through — but its key does not exist until the tunnel is opened, and the
|
||||||
|
/// account is whatever Spelunk then says. This stands in for the key until the route replaces it, together
|
||||||
|
/// with the username, as the connection is made through it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It authenticates nothing.</b> The SSH layer knows only passwords and private keys, and refuses any other
|
||||||
|
/// credential before it dials — so a path that forgot to take the route's key fails closed rather than
|
||||||
|
/// sending something else in its place.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed record SpelunkSignInPending : SshCredential
|
||||||
|
{
|
||||||
|
/// <summary>The one value; there is nothing in it to tell two apart.</summary>
|
||||||
|
public static SpelunkSignInPending Instance { get; } = new();
|
||||||
|
|
||||||
|
private SpelunkSignInPending()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,661 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spelunk's tunnel: the routes, the subprotocol and the close codes, as Spelunk defines them.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The same two steps as the server relay's, and on purpose, because the bridge on this side is the relay's
|
||||||
|
/// and it only has to be told where to go. An authenticated POST decides everything — whose key, which
|
||||||
|
/// server, whether the key may — and answers with a ticket; the WebSocket that spends the ticket carries no
|
||||||
|
/// authority of its own, and after the upgrade every binary message is simply the next bytes of the SSH
|
||||||
|
/// stream, both ways, with nothing else in the socket at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// This is Spelunk's contract, not DodoSSH's, which is why it lives here and not in
|
||||||
|
/// <c>DodoSSH.Contracts</c>: nothing on the DodoSSH server takes part in a Spelunk tunnel.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static class SpelunkTunnelProtocol
|
||||||
|
{
|
||||||
|
/// <summary>The only subprotocol this version speaks.</summary>
|
||||||
|
public const string SubProtocol = "spelunk.tunnel.v1";
|
||||||
|
|
||||||
|
/// <summary>The prefix of the second subprotocol an upgrade offers, which carries the ticket.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A header rather than the query string, for the reason the relay gives: a URL is what proxies, access
|
||||||
|
/// logs and traces keep. Spelunk echoes <see cref="SubProtocol"/> alone.
|
||||||
|
/// </remarks>
|
||||||
|
public const string TicketProtocolPrefix = "ticket.";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Close code for an agent that could not connect to the port on its own machine — or that is too old to
|
||||||
|
/// know what a tunnel is, in which case the reason says <see cref="UnknownStreamKind"/>.
|
||||||
|
/// </summary>
|
||||||
|
public const int TargetUnreachableCloseCode = 4502;
|
||||||
|
|
||||||
|
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
|
||||||
|
public const int AgentDisconnectedCloseCode = 4503;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What an agent that predates tunnels — or, asked to sign in, predates that — says, inside a
|
||||||
|
/// <see cref="TargetUnreachableCloseCode"/>.
|
||||||
|
/// </summary>
|
||||||
|
internal const string UnknownStreamKind = "Unknown stream kind";
|
||||||
|
|
||||||
|
/// <summary>Where a ticket for one server is asked for.</summary>
|
||||||
|
internal static string TicketsPath(Guid serverId) =>
|
||||||
|
string.Create(CultureInfo.InvariantCulture, $"/api/servers/{serverId}/tunnel-tickets");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens SSH connections to Spelunk-managed servers through Spelunk.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISpelunkTunnel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a tunnel to one server, ready for a connection to be made through it.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||||
|
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||||
|
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
|
||||||
|
/// <param name="port">
|
||||||
|
/// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the
|
||||||
|
/// agent starts an SSH server of its own and Spelunk ignores it.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="signIn">
|
||||||
|
/// The throwaway key to have Spelunk sign in with, or null for a plain tunnel to <paramref name="port"/>.
|
||||||
|
/// Its public half goes with the ticket request; the route hands the whole of it back, with the account
|
||||||
|
/// Spelunk named, for the connection made through it.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="cancellationToken">Cancellation.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
||||||
|
/// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will
|
||||||
|
/// use it and never ahead of one. The key is a parameter rather than a field of some request record
|
||||||
|
/// because a record prints its fields, and a key has no business in anything that might be logged.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
|
||||||
|
Task<SpelunkTunnelRoute> OpenAsync(
|
||||||
|
string apiBase,
|
||||||
|
Guid serverId,
|
||||||
|
string apiKey,
|
||||||
|
int port,
|
||||||
|
SpelunkSignIn? signIn,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An open Spelunk tunnel, one connection long.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same ownership as <see cref="RelayRoute"/>, because it is the same bridge: see
|
||||||
|
/// <see cref="IConnectionRoute"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SpelunkTunnelRoute : IConnectionRoute
|
||||||
|
{
|
||||||
|
/// <summary>The longest stretch of Spelunk's own close reason that is repeated to the user.</summary>
|
||||||
|
private const int MaxReasonLength = 200;
|
||||||
|
|
||||||
|
private readonly RelayBridge bridge;
|
||||||
|
private readonly int port;
|
||||||
|
|
||||||
|
internal SpelunkTunnelRoute(RelayBridge bridge, int port, SpelunkSignIn? signIn = null, string? user = null)
|
||||||
|
{
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.port = port;
|
||||||
|
SignIn = signIn;
|
||||||
|
User = signIn is null ? null : user ?? SpelunkSignIn.DefaultUser;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public SshLoopbackProxy Proxy => bridge.Proxy;
|
||||||
|
|
||||||
|
/// <summary>The key Spelunk was asked to sign in with, or null for a plain tunnel.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Handed back rather than kept by whoever asked, so the connection made through this route takes its
|
||||||
|
/// credential from the same place it takes its proxy, and cannot be given one without the other.
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkSignIn? SignIn { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The account Spelunk signs in as — its answer's <c>user</c>, or <see cref="SpelunkSignIn.DefaultUser"/>
|
||||||
|
/// when it named none — or null for a plain tunnel.
|
||||||
|
/// </summary>
|
||||||
|
public string? User { get; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// A normal close is not a failure worth rewording: it is the SSH server hanging up, and SSH.NET's own
|
||||||
|
/// account of that is the accurate one.
|
||||||
|
/// </remarks>
|
||||||
|
public string? Failure =>
|
||||||
|
bridge.CloseStatus is { } status
|
||||||
|
? Describe((int)status, bridge.CloseDescription, port, signingIn: SignIn is not null)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask DisposeAsync() => bridge.DisposeAsync();
|
||||||
|
|
||||||
|
/// <summary>What a close code from Spelunk means, for someone who has to do something about it.</summary>
|
||||||
|
internal static string? Describe(int code, string? reason, int port, bool signingIn = false) => code switch
|
||||||
|
{
|
||||||
|
SpelunkTunnelProtocol.TargetUnreachableCloseCode
|
||||||
|
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
|
||||||
|
signingIn
|
||||||
|
? "The Spelunk agent on this server is too old to sign you in. Update the agent, then try again."
|
||||||
|
: "The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
|
||||||
|
|
||||||
|
// Signing in, the agent connects to nothing: it starts an SSH server of its own, and what it could not
|
||||||
|
// do is that — most often because the server has no OpenSSH server to start, which Spelunk's reason
|
||||||
|
// says in its own words.
|
||||||
|
SpelunkTunnelProtocol.TargetUnreachableCloseCode when signingIn =>
|
||||||
|
"The Spelunk agent could not sign you in on this server"
|
||||||
|
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
|
||||||
|
+ ". Signing in through Spelunk needs OpenSSH's server (sshd) installed there.",
|
||||||
|
|
||||||
|
SpelunkTunnelProtocol.TargetUnreachableCloseCode =>
|
||||||
|
string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"The Spelunk agent could not connect to port {port} on the server itself")
|
||||||
|
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
|
||||||
|
+ ". Check that the SSH server is running and listening on that port.",
|
||||||
|
|
||||||
|
SpelunkTunnelProtocol.AgentDisconnectedCloseCode =>
|
||||||
|
"The Spelunk agent on this server disconnected from Spelunk. It may be restarting; try again in a "
|
||||||
|
+ "moment.",
|
||||||
|
|
||||||
|
(int)WebSocketCloseStatus.EndpointUnavailable => "Spelunk is restarting. Try again in a moment.",
|
||||||
|
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spelunk's own words, cut to one line of printable text, or null when there are none worth showing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Shown because they are specific — "connection refused" and "no route to host" are different problems
|
||||||
|
/// on the server — and trimmed because they are another program's output going onto a status line.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string? Readable(string? text)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder = new StringBuilder(Math.Min(text.Length, MaxReasonLength));
|
||||||
|
|
||||||
|
foreach (var character in text.Trim())
|
||||||
|
{
|
||||||
|
if (builder.Length == MaxReasonLength)
|
||||||
|
{
|
||||||
|
builder.Append('…');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.Append(char.IsControl(character) ? ' ' : character);
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A Spelunk tunnel that could not be opened, with the reason in its message.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Its own type for the reason <see cref="ServerRelayException"/> is one: the connect path must be able to
|
||||||
|
/// tell a refusal, which already says exactly what happened, from a connection failure it might explain by
|
||||||
|
/// probing — and probing a Spelunk host would dial an address that exists only to name its key.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SpelunkTunnelException : Exception
|
||||||
|
{
|
||||||
|
/// <summary>A refusal with nothing underneath it.</summary>
|
||||||
|
public SpelunkTunnelException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A refusal and what caused it.</summary>
|
||||||
|
public SpelunkTunnelException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A refusal Spelunk answered with a status.</summary>
|
||||||
|
public SpelunkTunnelException(string message, HttpStatusCode statusCode)
|
||||||
|
: base(message) =>
|
||||||
|
StatusCode = statusCode;
|
||||||
|
|
||||||
|
/// <summary>What Spelunk answered with, or null where the refusal came from somewhere else.</summary>
|
||||||
|
/// <remarks>Kept for tests and logs; the screen reads the message.</remarks>
|
||||||
|
public HttpStatusCode? StatusCode { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Spelunk tunnel: a ticket from the API, a socket, and the relay's bridge.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every way this can fail is turned into a sentence here, as <see cref="ServerRelay"/> does for the relay,
|
||||||
|
/// because the status codes are Spelunk's and the one place that should know what each means is the one
|
||||||
|
/// that asked.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Nothing from Spelunk's answer is trusted to go anywhere else.</b> The socket is opened on the API's
|
||||||
|
/// own scheme, host and port, with only the path taken from the answer — and only a path that is one — so a
|
||||||
|
/// ticket response cannot send this client, or the ticket, to a different machine.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||||
|
{
|
||||||
|
/// <summary>The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header.</summary>
|
||||||
|
private const int MaxTicketLength = 2048;
|
||||||
|
|
||||||
|
/// <summary>The longest account name taken from Spelunk's answer — well past what a Unix system allows.</summary>
|
||||||
|
private const int MaxUserLength = 64;
|
||||||
|
|
||||||
|
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
|
||||||
|
private const int MaxProblemBytes = 16 * 1024;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// One transport for the process, as the platform advises for <see cref="HttpClient"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody
|
||||||
|
/// watching "Connecting…" should hear that Spelunk is not answering while they still care.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly HttpClient SharedHttp = new(
|
||||||
|
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient http;
|
||||||
|
private readonly Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect;
|
||||||
|
|
||||||
|
/// <summary>A tunnel over the process's shared transport, which is how the application builds one.</summary>
|
||||||
|
public SpelunkTunnel()
|
||||||
|
: this(SharedHttp, DialAsync)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A tunnel whose requests and socket are supplied, for tests.</summary>
|
||||||
|
internal SpelunkTunnel(
|
||||||
|
HttpClient http,
|
||||||
|
Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect)
|
||||||
|
{
|
||||||
|
this.http = http;
|
||||||
|
this.connect = connect;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||||
|
string apiBase,
|
||||||
|
Guid serverId,
|
||||||
|
string apiKey,
|
||||||
|
int port,
|
||||||
|
SpelunkSignIn? signIn,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var socket = await connect(
|
||||||
|
SocketUrl(normalized, ticket.Path!),
|
||||||
|
[SpelunkTunnelProtocol.SubProtocol, SpelunkTunnelProtocol.TicketProtocolPrefix + ticket.Ticket],
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port, signIn, ticket.User);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where the tunnel socket is: the API's own origin, on the WebSocket scheme, at the path Spelunk named.
|
||||||
|
/// </summary>
|
||||||
|
internal static Uri SocketUrl(string apiBase, string path)
|
||||||
|
{
|
||||||
|
var secure = apiBase.StartsWith("https://", StringComparison.Ordinal);
|
||||||
|
var rest = apiBase[(secure ? "https".Length : "http".Length)..];
|
||||||
|
|
||||||
|
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Trims the stored key and refuses one that could not be a header value.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A key pasted into a credential arrives with a newline behind it often enough to be worth handling, and
|
||||||
|
/// one with a space in the middle would otherwise surface as a <see cref="FormatException"/> from inside
|
||||||
|
/// <see cref="HttpClient"/> rather than as a sentence about the credential.
|
||||||
|
/// </remarks>
|
||||||
|
private static string Sanitise(string apiKey)
|
||||||
|
{
|
||||||
|
var trimmed = apiKey?.Trim() ?? string.Empty;
|
||||||
|
|
||||||
|
if (trimmed.Length == 0 || trimmed.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
"The credential chosen for the Spelunk API key does not hold one. A Spelunk API key is one "
|
||||||
|
+ "unbroken run of characters starting with spelunk_; paste it as the credential's password.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SpelunkTicketDocument> IssueTicketAsync(
|
||||||
|
string apiBase,
|
||||||
|
Guid serverId,
|
||||||
|
string apiKey,
|
||||||
|
int port,
|
||||||
|
SpelunkSignIn? signIn,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(
|
||||||
|
HttpMethod.Post,
|
||||||
|
new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute));
|
||||||
|
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||||
|
|
||||||
|
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
|
||||||
|
// do not need chunked encoding, and not every proxy in front of an API accepts it.
|
||||||
|
//
|
||||||
|
// Signing in sends the public key and no port: the agent starts an SSH server of its own rather than
|
||||||
|
// connecting to one, and Spelunk ignores the port beside a key anyway.
|
||||||
|
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
||||||
|
signIn is null
|
||||||
|
? new SpelunkTicketRequestDocument { Port = port }
|
||||||
|
: new SpelunkTicketRequestDocument
|
||||||
|
{
|
||||||
|
SignIn = new SpelunkSignInRequestDocument { PublicKey = signIn.PublicKeyLine },
|
||||||
|
},
|
||||||
|
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
|
||||||
|
{
|
||||||
|
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException exception)
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
$"Spelunk could not be reached at {apiBase}: {exception.Message}",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
$"Spelunk at {apiBase} did not answer in time. Try again in a moment.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var problem = await ReadProblemAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
throw new SpelunkTunnelException(Describe(response.StatusCode, problem), response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ReadTicketAsync(response, signingIn: signIn is not null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the ticket, and refuses one this client would have to take on trust.</summary>
|
||||||
|
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
bool signingIn,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SpelunkTicketDocument? ticket;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ticket = await response.Content
|
||||||
|
.ReadFromJsonAsync(SpelunkTunnelJsonContext.Default.SpelunkTicketDocument, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (JsonException exception)
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(Unintelligible, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ticket goes into a header as part of a subprotocol name, which may hold only token characters —
|
||||||
|
// base64url is all of those, and anything else would fail inside the WebSocket client instead. The
|
||||||
|
// path is appended to the API's own origin, so it has to be a path: "//elsewhere" or "@elsewhere"
|
||||||
|
// would turn it into a different host.
|
||||||
|
if (ticket is not { Ticket: { Length: > 0 and <= MaxTicketLength } value, Path: { } path }
|
||||||
|
|| !value.All(IsBase64UrlCharacter)
|
||||||
|
|| !path.StartsWith('/')
|
||||||
|
|| path.StartsWith("//", StringComparison.Ordinal)
|
||||||
|
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|
||||||
|
|| (ticket.Subprotocol is { } named
|
||||||
|
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|
||||||
|
|| (signingIn && ticket.User is { } user && !IsAccountName(user)))
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(Unintelligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ticket;
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string Unintelligible =
|
||||||
|
"Spelunk answered the tunnel request in a way this app does not understand. Update DodoSSH, or check "
|
||||||
|
+ "that the API address is Spelunk's.";
|
||||||
|
|
||||||
|
private static bool IsBase64UrlCharacter(char character) =>
|
||||||
|
char.IsAsciiLetterOrDigit(character) || character is '-' or '_';
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the account Spelunk named is one an SSH login could be for: a short run of the characters
|
||||||
|
/// Unix account names are made of.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Checked because it goes to the server as the login and onto the tab and the status line, and an
|
||||||
|
/// answer carrying anything else is not one this client understands.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool IsAccountName(string user) =>
|
||||||
|
user.Length is > 0 and <= MaxUserLength
|
||||||
|
&& user.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.');
|
||||||
|
|
||||||
|
/// <summary>What a refused ticket means, in the words of the status and of Spelunk's own detail.</summary>
|
||||||
|
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
|
||||||
|
{
|
||||||
|
var detail = SpelunkTunnelRoute.Readable(problem?.Detail);
|
||||||
|
|
||||||
|
return status switch
|
||||||
|
{
|
||||||
|
// Spelunk's validation says which field and why; for a sign-in it is the public key.
|
||||||
|
HttpStatusCode.BadRequest =>
|
||||||
|
"Spelunk would not accept the request to open the tunnel"
|
||||||
|
+ ((SpelunkTunnelRoute.Readable(PublicKeyError(problem))
|
||||||
|
?? detail
|
||||||
|
?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said
|
||||||
|
? $": {said}"
|
||||||
|
: ". Update DodoSSH, or check that the API address is Spelunk's."),
|
||||||
|
|
||||||
|
HttpStatusCode.Unauthorized =>
|
||||||
|
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
|
||||||
|
+ "Spelunk and paste it into the credential this host uses for it.",
|
||||||
|
|
||||||
|
// The detail is the useful half here: it says which role the key has and which it needs.
|
||||||
|
HttpStatusCode.Forbidden =>
|
||||||
|
"The Spelunk API key is not allowed to open a terminal on this server"
|
||||||
|
+ (detail is null ? ". Opening one needs a key with the Admin role." : $": {detail}"),
|
||||||
|
|
||||||
|
HttpStatusCode.NotFound =>
|
||||||
|
"Spelunk does not know this server in the API key's organisation. It may have been removed, or "
|
||||||
|
+ "the key belongs to a different organisation.",
|
||||||
|
|
||||||
|
HttpStatusCode.ServiceUnavailable =>
|
||||||
|
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the "
|
||||||
|
+ "agent is running on the server, then try again.",
|
||||||
|
|
||||||
|
_ => string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"Spelunk would not open the tunnel (HTTP {(int)status})")
|
||||||
|
+ ((detail ?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said ? $": {said}" : "."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a validation problem says about the sign-in key, if it says anything.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Matched on the end of the name and without regard to case, because a validator may name the field
|
||||||
|
/// <c>publicKey</c>, <c>PublicKey</c> or <c>signIn.publicKey</c>, and all three mean this one.
|
||||||
|
/// </remarks>
|
||||||
|
private static string? PublicKeyError(SpelunkProblemDocument? problem) =>
|
||||||
|
problem?.Errors?
|
||||||
|
.FirstOrDefault(field => field.Key.EndsWith("publicKey", StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Value?
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
/// <summary>Reads an RFC 7807 body if there is one; null when there is not, or it will not parse.</summary>
|
||||||
|
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return body.Length is 0 or > MaxProblemBytes
|
||||||
|
? null
|
||||||
|
: JsonSerializer.Deserialize(body, SpelunkTunnelJsonContext.Default.SpelunkProblemDocument);
|
||||||
|
}
|
||||||
|
catch (JsonException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Opens the tunnel socket, and translates a refused upgrade into its reason.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The status of a refused upgrade is collected, as the relay's dialler does. A 401 is a ticket Spelunk
|
||||||
|
/// would not take — spent, or expired on the way — and asking again is the remedy; a 503 is the agent
|
||||||
|
/// going away in the moment between the ticket and the socket.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<WebSocket> DialAsync(
|
||||||
|
Uri url,
|
||||||
|
IReadOnlyList<string> protocols,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var socket = new ClientWebSocket();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var protocol in protocols)
|
||||||
|
{
|
||||||
|
socket.Options.AddSubProtocol(protocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.Options.CollectHttpResponseDetails = true;
|
||||||
|
|
||||||
|
await socket.ConnectAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (WebSocketException exception)
|
||||||
|
{
|
||||||
|
var status = socket.HttpStatusCode;
|
||||||
|
socket.Dispose();
|
||||||
|
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
status switch
|
||||||
|
{
|
||||||
|
HttpStatusCode.Unauthorized => "Spelunk refused the tunnel ticket. Try connecting again.",
|
||||||
|
HttpStatusCode.ServiceUnavailable => Describe(status, problem: null),
|
||||||
|
_ => $"The Spelunk tunnel could not be opened: {exception.Message}",
|
||||||
|
},
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
socket.Dispose();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(socket.SubProtocol, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
socket.Abort();
|
||||||
|
socket.Dispose();
|
||||||
|
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
"Spelunk answered the tunnel in a protocol this app does not speak. Update DodoSSH or Spelunk.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The body of a ticket request.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Either a port or a sign-in, never both: the one not asked for is null and left out of the JSON, so a plain
|
||||||
|
/// tunnel still asks for exactly <c>{"port":22}</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SpelunkTicketRequestDocument
|
||||||
|
{
|
||||||
|
public int? Port { get; set; }
|
||||||
|
|
||||||
|
public SpelunkSignInRequestDocument? SignIn { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a sign-in sends: the one-line OpenSSH public key the agent's SSH server is to accept.</summary>
|
||||||
|
internal sealed class SpelunkSignInRequestDocument
|
||||||
|
{
|
||||||
|
public string? PublicKey { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A ticket as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
||||||
|
internal sealed class SpelunkTicketDocument
|
||||||
|
{
|
||||||
|
public string? Ticket { get; set; }
|
||||||
|
|
||||||
|
public DateTimeOffset? ExpiresAt { get; set; }
|
||||||
|
|
||||||
|
public string? Path { get; set; }
|
||||||
|
|
||||||
|
public string? Subprotocol { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The account a sign-in logs in as; absent or null for a plain tunnel.</summary>
|
||||||
|
public string? User { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The fields of an RFC 7807 problem this client repeats.</summary>
|
||||||
|
internal sealed class SpelunkProblemDocument
|
||||||
|
{
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
public string? Detail { get; set; }
|
||||||
|
|
||||||
|
/// <summary>A validation problem's messages by field, as ASP.NET Core writes them.</summary>
|
||||||
|
public Dictionary<string, string[]>? Errors { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
|
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
|
||||||
|
[JsonSerializable(typeof(SpelunkTicketDocument))]
|
||||||
|
[JsonSerializable(typeof(SpelunkProblemDocument))]
|
||||||
|
internal sealed partial class SpelunkTunnelJsonContext : JsonSerializerContext;
|
||||||
@@ -145,6 +145,26 @@ public sealed class VaultKnownHostStore : IKnownHostStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>From the snapshot, like <see cref="FindAsync"/>, and for the same reason.</remarks>
|
||||||
|
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
IReadOnlyList<HostKeyPresentation> found = pins.Values
|
||||||
|
.Where(pin =>
|
||||||
|
KnownHostIdentity.Comparer.Equals(pin.Secret.Host, host) && pin.Secret.Port == port)
|
||||||
|
.Select(pin => new HostKeyPresentation(
|
||||||
|
pin.Secret.Host, pin.Secret.Port, pin.Secret.Algorithm, pin.Secret.Fingerprint))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// The vault is not open, so there is nowhere to record trust.
|
/// The vault is not open, so there is nowhere to record trust.
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ public sealed partial class VaultSession : IAsyncDisposable
|
|||||||
Tags = new TagRepository(Items, Outbox, keyring, activity);
|
Tags = new TagRepository(Items, Outbox, keyring, activity);
|
||||||
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
|
Snippets = new SnippetRepository(Items, Outbox, keyring, activity);
|
||||||
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
|
ObjectStores = new ObjectStoreRepository(Items, Outbox, keyring, activity);
|
||||||
|
SpelunkKeys = new SpelunkKeyRepository(Items, Outbox, keyring, activity);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
/// <summary>Who this session belongs to, and the material that unlocked it.</summary>
|
||||||
@@ -200,6 +201,13 @@ public sealed partial class VaultSession : IAsyncDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public ObjectStoreRepository ObjectStores { get; }
|
public ObjectStoreRepository ObjectStores { get; }
|
||||||
|
|
||||||
|
/// <summary>Spelunk API keys and the addresses they may be sent to, decrypted.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Read with the rest of the vault, and at connect from that list, as a credential's password is. The
|
||||||
|
/// address in each is what lets a link from Spelunk connect without asking.
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkKeyRepository SpelunkKeys { get; }
|
||||||
|
|
||||||
/// <summary>The connections this vault has recorded, decrypted.</summary>
|
/// <summary>The connections this vault has recorded, decrypted.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
|
/// Written through <see cref="ConnectionRecorder"/> rather than directly by anything that connects. An
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What unlocking with this machine's device key is called here, and what it asks the person for.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The words used to be Windows's everywhere: "USE WINDOWS HELLO" on a Mac, "Waiting for Windows…" over a
|
||||||
|
/// Linux PIN prompt, and a settings row that told a MacBook it needed a TPM. Each desktop has its own device
|
||||||
|
/// key store with its own gesture — see <c>WindowsDeviceKeyStore</c>, <c>MacDeviceKeyStore</c> and
|
||||||
|
/// <c>LinuxDeviceKeyStore</c> — and a button has to name the one the person is about to be shown, or it reads
|
||||||
|
/// as a feature for some other machine.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// One record rather than a property per sentence on the shell, so that the five places these words appear
|
||||||
|
/// cannot disagree about which platform they are on.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Name">What the feature is called, as a settings row's title.</param>
|
||||||
|
/// <param name="Button">The unlock card's button, in the capitals every button here uses.</param>
|
||||||
|
/// <param name="Wait">The status line while the platform's prompt is up.</param>
|
||||||
|
/// <param name="Gesture">What opens the keychain instead of the passphrase, to finish a sentence.</param>
|
||||||
|
/// <param name="Keeper">What holds the key, to finish "Registered · …".</param>
|
||||||
|
/// <param name="Needs">What a machine with no device key store lacks, as a sentence of its own.</param>
|
||||||
|
internal sealed record DeviceUnlockWording(
|
||||||
|
string Name,
|
||||||
|
string Button,
|
||||||
|
string Wait,
|
||||||
|
string Gesture,
|
||||||
|
string Keeper,
|
||||||
|
string Needs)
|
||||||
|
{
|
||||||
|
internal static DeviceUnlockWording Windows { get; } = new(
|
||||||
|
"Windows Hello on this machine",
|
||||||
|
"USE WINDOWS HELLO",
|
||||||
|
"Waiting for Windows…",
|
||||||
|
"a Windows confirmation",
|
||||||
|
"the TPM holds a device key",
|
||||||
|
"That needs a TPM and a Windows keystore willing to release the key.");
|
||||||
|
|
||||||
|
internal static DeviceUnlockWording Mac { get; } = new(
|
||||||
|
"Touch ID on this Mac",
|
||||||
|
"USE TOUCH ID",
|
||||||
|
"Waiting for Touch ID…",
|
||||||
|
"Touch ID or your login password",
|
||||||
|
"the Secure Enclave holds a device key",
|
||||||
|
"That needs a Mac with a Secure Enclave and a login password set.");
|
||||||
|
|
||||||
|
internal static DeviceUnlockWording Linux { get; } = new(
|
||||||
|
"A device PIN on this machine",
|
||||||
|
"USE DEVICE PIN",
|
||||||
|
"Waiting for your PIN…",
|
||||||
|
"a PIN the TPM checks",
|
||||||
|
"the TPM holds a device key",
|
||||||
|
"That needs a TPM 2.0 this account is allowed to use.");
|
||||||
|
|
||||||
|
internal static DeviceUnlockWording Android { get; } = new(
|
||||||
|
"Fingerprint unlock on this phone",
|
||||||
|
"USE FINGERPRINT",
|
||||||
|
"Waiting for your fingerprint…",
|
||||||
|
"your fingerprint",
|
||||||
|
"the phone's keystore holds a device key",
|
||||||
|
"That needs a screen lock and a keystore willing to release the key.");
|
||||||
|
|
||||||
|
/// <summary>The wording for the platform this process is running on.</summary>
|
||||||
|
internal static DeviceUnlockWording ForThisPlatform { get; } =
|
||||||
|
OperatingSystem.IsAndroid() ? Android
|
||||||
|
: OperatingSystem.IsMacOS() ? Mac
|
||||||
|
: OperatingSystem.IsLinux() ? Linux
|
||||||
|
: Windows;
|
||||||
|
|
||||||
|
/// <summary>The settings caption for a machine that can register.</summary>
|
||||||
|
internal string Offer =>
|
||||||
|
$"Registers this machine so a later launch can open the keychain with {Gesture} instead of your passphrase. Your passphrase keeps working.";
|
||||||
|
|
||||||
|
/// <summary>The settings caption for a machine that has.</summary>
|
||||||
|
internal string Registered => $"Registered · {Keeper} that can open the keychain with {Gesture}.";
|
||||||
|
|
||||||
|
/// <summary>The settings caption for a machine that cannot.</summary>
|
||||||
|
internal string Unavailable =>
|
||||||
|
$"This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. {Needs}";
|
||||||
|
|
||||||
|
/// <summary>The unlock button's tooltip.</summary>
|
||||||
|
internal string Tip => $"Opens the keychain with this machine's device key, after {Gesture}.";
|
||||||
|
}
|
||||||
@@ -566,7 +566,7 @@ internal sealed partial class HetznerImportViewModel(
|
|||||||
// and visible rather than harmless and confusing.
|
// and visible rather than harmless and confusing.
|
||||||
Refresh(resetSelection: true);
|
Refresh(resetSelection: true);
|
||||||
|
|
||||||
Status = WhatWasStored(outcome.Hosts);
|
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
|
||||||
|
|
||||||
Step = HetznerImportStep.Done;
|
Step = HetznerImportStep.Done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,27 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
|||||||
/// </param>
|
/// </param>
|
||||||
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
||||||
|
|
||||||
/// <summary>What an import stored.</summary>
|
/// <summary>What an import stored, and what it did not.</summary>
|
||||||
/// <param name="Hosts">Hosts written.</param>
|
/// <param name="Hosts">Hosts written.</param>
|
||||||
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
||||||
internal sealed record ImportOutcome(int Hosts, int Keys);
|
/// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
|
||||||
|
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
|
||||||
|
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
|
||||||
|
{
|
||||||
|
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
|
||||||
|
internal string Caveats =>
|
||||||
|
(Refused switch
|
||||||
|
{
|
||||||
|
0 => string.Empty,
|
||||||
|
1 => " 1 was skipped because it is not a host this keychain can connect to.",
|
||||||
|
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
|
||||||
|
})
|
||||||
|
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -409,7 +426,7 @@ internal sealed partial class ImportViewModel(
|
|||||||
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
||||||
};
|
};
|
||||||
|
|
||||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
|
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The shell's half of a link from Spelunk: holding it until there is a vault to act on it.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class MainWindowViewModel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Acts on a <c>dodossh://spelunk/connect</c> link now, or as soon as a vault is open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
|
||||||
|
/// passed down the doorbell, or one the platform delivered to the running application. The link has
|
||||||
|
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
|
||||||
|
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
|
||||||
|
/// host already sends it to.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
||||||
|
/// asked for the passphrase is the ordinary first half of this; losing the click at the second half would
|
||||||
|
/// make the button look broken. It is not kept across a restart.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal void OpenLink(SpelunkLink link)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(link);
|
||||||
|
|
||||||
|
pendingLink = link;
|
||||||
|
|
||||||
|
if (State is ShellState.Unlocked && Vault is not null)
|
||||||
|
{
|
||||||
|
DeliverPendingLink();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State is ShellState.Locked)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Unlock to open {link.Name} from Spelunk.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands a waiting link to the open vault, on the Hosts screen where its answer appears.</summary>
|
||||||
|
private void DeliverPendingLink()
|
||||||
|
{
|
||||||
|
if (pendingLink is not { } link || Vault is not { } vault)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLink = null;
|
||||||
|
|
||||||
|
ShowScreen(ShellScreen.Hosts);
|
||||||
|
|
||||||
|
// Not awaited: a connect reports its own progress and failures on the vault's status line and in its
|
||||||
|
// tab, as a tap on the row does, and this is called from places that must not wait on a handshake.
|
||||||
|
_ = vault.OpenSpelunkLinkAsync(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,6 +360,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly ISshReachabilityProbe? reachability;
|
private readonly ISshReachabilityProbe? reachability;
|
||||||
|
|
||||||
|
/// <summary>What opens a Spelunk tunnel, handed to each vault this shell opens.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Held for the reason <see cref="reachability"/> is: the vault is built on unlock, not here. Null means
|
||||||
|
/// the real one, which is what both heads use; a test passes one so a Spelunk host's connect never leaves
|
||||||
|
/// the process.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly ISpelunkTunnel? spelunk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One, and the latest: a link is somebody pressing a button in Spelunk, and a second press while this
|
||||||
|
/// machine is still locked means the second server, not both. Delivered as soon as a vault opens, and
|
||||||
|
/// dropped if the application closes first — it is a request to do something now, not something to
|
||||||
|
/// remember.
|
||||||
|
/// </remarks>
|
||||||
|
private SpelunkLink? pendingLink;
|
||||||
|
|
||||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||||
private readonly Action<Action> post;
|
private readonly Action<Action> post;
|
||||||
|
|
||||||
@@ -569,9 +588,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Action<Action>? post = null,
|
Action<Action>? post = null,
|
||||||
Func<string, Task>? openInEditor = null,
|
Func<string, Task>? openInEditor = null,
|
||||||
Func<string, Action, IDisposable>? watchFile = null,
|
Func<string, Action, IDisposable>? watchFile = null,
|
||||||
ISshReachabilityProbe? reachability = null)
|
ISshReachabilityProbe? reachability = null,
|
||||||
|
ISpelunkTunnel? spelunk = null)
|
||||||
{
|
{
|
||||||
this.reachability = reachability;
|
this.reachability = reachability;
|
||||||
|
this.spelunk = spelunk;
|
||||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||||
|
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
@@ -1774,6 +1795,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ActiveSettingsPage = null;
|
ActiveSettingsPage = null;
|
||||||
IsImportOpen = false;
|
IsImportOpen = false;
|
||||||
IsHetznerImportOpen = false;
|
IsHetznerImportOpen = false;
|
||||||
|
|
||||||
|
// Drawn over Settings → Vaults. Left open, it was still up the next time Settings opened, over
|
||||||
|
// whichever page that was.
|
||||||
|
vaults.IsMembersPanelOpen = false;
|
||||||
Screen = settingsReturnScreen;
|
Screen = settingsReturnScreen;
|
||||||
Surface = settingsReturnSurface;
|
Surface = settingsReturnSurface;
|
||||||
}
|
}
|
||||||
@@ -2108,12 +2133,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
|
try
|
||||||
.ConfigureAwait(true);
|
{
|
||||||
|
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it
|
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing
|
||||||
// second means the menu and the screen behind it never disagree, even for a frame.
|
// it second means the menu and the screen behind it never disagree, even for a frame.
|
||||||
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
|
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// A command's exception is rethrown on the UI thread, where nothing catches it — so a cache that
|
||||||
|
// could not be written to, or a vault locked halfway through, used to close the application. The
|
||||||
|
// switches are redrawn from what is actually stored, so the menu does not claim the change.
|
||||||
|
RebuildVaultToggles();
|
||||||
|
StatusMessage = $"Could not change which vaults are shown: {exception.Message}";
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
RebuildVaultToggles();
|
RebuildVaultToggles();
|
||||||
|
|
||||||
@@ -2794,8 +2832,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
|
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
|
||||||
/// deciding whether the prompt in front of them is the one this application asked for.
|
/// deciding whether the prompt in front of them is the one this application asked for.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string GestureWait =>
|
private static string GestureWait => DeviceUnlockWording.ForThisPlatform.Wait;
|
||||||
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
|
|
||||||
|
/// <summary>What unlocking with the device key is called on this platform; the views read it whole.</summary>
|
||||||
|
internal DeviceUnlockWording DeviceUnlock { get; } = DeviceUnlockWording.ForThisPlatform;
|
||||||
|
|
||||||
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
|
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2923,13 +2963,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// â—† Which lock this unlock started under. Every await below is a moment the user can press Lock, and
|
||||||
|
// LockAsync can only close what has been handed over by then — so an unlock still attaching its
|
||||||
|
// stores used to finish after the lock and open the vault behind the lock screen.
|
||||||
|
var generation = locks;
|
||||||
|
|
||||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
|
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
|
||||||
// to "everything shown", because a vault somebody set aside last week should still be set aside.
|
// to "everything shown", because a vault somebody set aside last week should still be set aside.
|
||||||
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
|
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Vault = new VaultViewModel(
|
if (generation != locks)
|
||||||
|
{
|
||||||
|
await AbandonAsync(session).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opened = Vault = new VaultViewModel(
|
||||||
session,
|
session,
|
||||||
workspace,
|
workspace,
|
||||||
knownHosts,
|
knownHosts,
|
||||||
@@ -2938,7 +2989,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
connectionLog,
|
connectionLog,
|
||||||
visibility,
|
visibility,
|
||||||
reachability);
|
reachability,
|
||||||
|
spelunk);
|
||||||
State = ShellState.Unlocked;
|
State = ShellState.Unlocked;
|
||||||
|
|
||||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||||
@@ -2952,21 +3004,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// are both !CanRegisterDevice, and only the second has anything to withdraw.
|
// are both !CanRegisterDevice, and only the second has anything to withdraw.
|
||||||
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
|
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
|
||||||
|
|
||||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
await OpenAsync(opened, generation, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
// After the load, because the switches are built from the vaults the session admitted and the
|
/// <summary>Loads the vault just handed over, and starts it syncing — unless a lock has closed it since.</summary>
|
||||||
// keyring is filled during it — before, and a machine with a team vault would come up with one
|
/// <remarks>
|
||||||
// switch until something else rebuilt them.
|
/// From here the vault belongs to the shell, so a lock closes it itself: there is nothing to undo, only
|
||||||
RebuildVaultToggles();
|
/// nothing more to do to a vault that is gone. Before these checks, a lock pressed while the first load
|
||||||
|
/// ran left this to carry on into a disposed session and a vault the shell no longer held.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task OpenAsync(VaultViewModel opened, int generation, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (generation != locks)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
await opened.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
// empty one would leave its picker blank until the next unlock.
|
|
||||||
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
|
if (generation != locks)
|
||||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
{
|
||||||
// second old.
|
return;
|
||||||
RefreshConnectedHosts();
|
}
|
||||||
|
|
||||||
|
AfterTheFirstLoad(opened);
|
||||||
|
|
||||||
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
||||||
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
||||||
@@ -2981,7 +3042,47 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// and unlocking must never wait on one. Everything the unlock screen promises about working
|
// and unlocking must never wait on one. Everything the unlock screen promises about working
|
||||||
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
|
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
|
||||||
// the titlebar says OFFLINE until the round trip this starts has an answer.
|
// the titlebar says OFFLINE until the round trip this starts has an answer.
|
||||||
Vault.StartAutoSync();
|
opened.StartAutoSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts away an unlock that a lock overtook before the vault was handed over.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything <see cref="LockAsync"/> would have closed had it been here to close: the stores attached to
|
||||||
|
/// this session, the switches read from it, and the session itself, whose keys nothing else now holds.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task AbandonAsync(VaultSession session)
|
||||||
|
{
|
||||||
|
knownHosts.Close();
|
||||||
|
connectionLog.Close();
|
||||||
|
visibility = null;
|
||||||
|
|
||||||
|
await session.DisposeAsync().ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Split out of <see cref="AdoptAsync"/> for length rather than for reuse; the order is still the one that
|
||||||
|
/// method's remarks call load-bearing, and it is kept here as it was there.
|
||||||
|
/// </remarks>
|
||||||
|
private void AfterTheFirstLoad(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
// After the load, because the switches are built from the vaults the session admitted and the
|
||||||
|
// keyring is filled during it — before, and a machine with a team vault would come up with one
|
||||||
|
// switch until something else rebuilt them.
|
||||||
|
RebuildVaultToggles();
|
||||||
|
|
||||||
|
// 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, 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
|
||||||
|
// second old.
|
||||||
|
RefreshConnectedHosts();
|
||||||
|
|
||||||
|
// After the hosts are loaded, because what a link does depends on whether one of them is already
|
||||||
|
// the server it names.
|
||||||
|
DeliverPendingLink();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -3217,6 +3318,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LockAsync()
|
private async Task LockAsync()
|
||||||
{
|
{
|
||||||
|
// Counted first, so an unlock still on its way in sees it at its next step and stands down rather than
|
||||||
|
// opening the vault after this has run. See AdoptAsync.
|
||||||
|
locks++;
|
||||||
|
|
||||||
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
|
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
|
||||||
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
|
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
|
||||||
// reappearing behind a lock screen.
|
// reappearing behind a lock screen.
|
||||||
@@ -3264,6 +3369,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isConfirmingSignOut;
|
private bool isConfirmingSignOut;
|
||||||
|
|
||||||
|
/// <summary>How many times this shell has been locked, for an unlock to notice it was overtaken.</summary>
|
||||||
|
private int locks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What signing out costs, on this machine, right now.
|
/// What signing out costs, on this machine, right now.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4476,7 +4584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||||
{
|
{
|
||||||
tab.Ended(e.Outcome.End, e.Outcome.Description);
|
tab.Ended(e.Outcome.End, e.Outcome.Description, clock.GetUtcNow());
|
||||||
|
|
||||||
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
|
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
|
||||||
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
|
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
|
||||||
@@ -4956,7 +5064,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var elapsed = clock.GetUtcNow() - started;
|
// Up to the end, for a shell that has one: how long it ran, not how long ago it started.
|
||||||
|
var until = Surface is ShellSurface.Terminal && SelectedTab?.EndedAt is { } ended
|
||||||
|
? ended
|
||||||
|
: clock.GetUtcNow();
|
||||||
|
|
||||||
|
var elapsed = until - started;
|
||||||
|
|
||||||
if (elapsed < TimeSpan.Zero)
|
if (elapsed < TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
@@ -5071,6 +5184,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Screen = ShellScreen.Hosts;
|
Screen = ShellScreen.Hosts;
|
||||||
Surface = ShellSurface.Page;
|
Surface = ShellSurface.Page;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Settings is a mode of the unlocked window, and it brings its own title bar while the main one
|
||||||
|
// is hidden. Locking or signing out from it — LOCK NOW is on Preferences, sign-out on Account —
|
||||||
|
// left the window in settings mode under the lock card: no title bar, so nothing to drag, minimise
|
||||||
|
// or close, and an unlock that came back to Settings rather than to Hosts.
|
||||||
|
LeaveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An observable list whose whole contents can be swapped and announced once.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For the lists rebuilt wholesale from the vault. They used to be emptied with <c>Clear</c> and refilled an
|
||||||
|
/// <c>Add</c> at a time, which announced every row separately — and two other view models listen to the host
|
||||||
|
/// list and redo their own work on every announcement: the transfers screen refilled its host picker, and the
|
||||||
|
/// shell walked every host against every tab. A reload of n hosts was n of each, so roughly n² rows touched;
|
||||||
|
/// three hundred hosts came to some ninety thousand notifications per reload.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// One <see cref="NotifyCollectionChangedAction.Reset"/> instead, which is also exactly what <c>Clear</c> has
|
||||||
|
/// always said first, so a listener that coped with the old sequence copes with this. Nothing listening to
|
||||||
|
/// these lists reads the items an announcement carries; they all re-read the list.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ReplaceableCollection<T> : ObservableCollection<T>
|
||||||
|
{
|
||||||
|
/// <summary>Makes <paramref name="items"/> the whole of the list, and says so once.</summary>
|
||||||
|
internal void ReplaceAll(IEnumerable<T> items)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
||||||
|
CheckReentrancy();
|
||||||
|
|
||||||
|
Items.Clear();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
Items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,6 +274,15 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal DateTimeOffset? StartedAt { get; set; }
|
internal DateTimeOffset? StartedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the session behind this tab ended, or null while it has not.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What stops the status bar's timer. It used to go on counting from <see cref="StartedAt"/> to now over a
|
||||||
|
/// shell that had exited an hour earlier, which made "how long that shell ran" — the fact the remark above
|
||||||
|
/// keeps <see cref="StartedAt"/> for — the one thing it no longer said. Stamped by <see cref="Ended"/> from
|
||||||
|
/// the shell's clock, and cleared whenever a new session takes the tab.
|
||||||
|
/// </remarks>
|
||||||
|
internal DateTimeOffset? EndedAt { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -620,6 +629,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
// stale-fact failure this pair exists to remove.
|
// stale-fact failure this pair exists to remove.
|
||||||
EndReason = null;
|
EndReason = null;
|
||||||
EndDescription = null;
|
EndDescription = null;
|
||||||
|
EndedAt = null;
|
||||||
|
|
||||||
IsLive = true;
|
IsLive = true;
|
||||||
State = TerminalTabState.Open;
|
State = TerminalTabState.Open;
|
||||||
@@ -679,13 +689,14 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
|
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal void Ended(SshSessionEnd end, string description)
|
internal void Ended(SshSessionEnd end, string description, DateTimeOffset at)
|
||||||
{
|
{
|
||||||
if (State is not TerminalTabState.Open)
|
if (State is not TerminalTabState.Open)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EndedAt ??= at;
|
||||||
EndReason = end;
|
EndReason = end;
|
||||||
EndDescription = description;
|
EndDescription = description;
|
||||||
IsLive = false;
|
IsLive = false;
|
||||||
@@ -733,6 +744,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
// Cleared with the session they were facts about. Keeping them would put the previous connection's
|
// Cleared with the session they were facts about. Keeping them would put the previous connection's
|
||||||
// cipher in the status bar beside a handshake that has not negotiated one yet.
|
// cipher in the status bar beside a handshake that has not negotiated one yet.
|
||||||
StartedAt = null;
|
StartedAt = null;
|
||||||
|
EndedAt = null;
|
||||||
Cipher = null;
|
Cipher = null;
|
||||||
HostKeyAlgorithm = null;
|
HostKeyAlgorithm = null;
|
||||||
IdentityLabel = null;
|
IdentityLabel = null;
|
||||||
|
|||||||
@@ -867,18 +867,35 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
||||||
/// be a second decrypted copy of a secret for no gain.
|
/// be a second decrypted copy of a secret for no gain.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private HostRowViewModel? selectedHost;
|
private HostRowViewModel? selectedHost;
|
||||||
|
|
||||||
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
|
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
|
||||||
/// <inheritdoc cref="Hosts" path="/remarks" />
|
/// <inheritdoc cref="Hosts" path="/remarks" />
|
||||||
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
|
internal ReplaceableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ObjectStoreRowViewModel? selectedBucket;
|
private ObjectStoreRowViewModel? selectedBucket;
|
||||||
|
|
||||||
|
/// <summary>The host and bucket somebody chose, as opposed to whatever the pickers show this instant.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="RefreshHosts"/> re-finds the selection by id, and it runs on every change to the vault's
|
||||||
|
/// lists — including the <c>Clear()</c> a reload starts with, at which point there is nothing to re-find
|
||||||
|
/// and the picker's own selection becomes null. Re-finding from that null on the next <c>Add</c> moved the
|
||||||
|
/// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then
|
||||||
|
/// dialled the first host with the password typed for theirs. So the choice is remembered here, written
|
||||||
|
/// only when a person changes the picker, and is what every refresh looks for.
|
||||||
|
/// </remarks>
|
||||||
|
private Guid? chosenHost;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="chosenHost" path="/remarks" />
|
||||||
|
private Guid? chosenBucket;
|
||||||
|
|
||||||
|
/// <summary>Whether a selection change is <see cref="RefreshHosts"/> putting things back, not a choice.</summary>
|
||||||
|
private bool refreshingPickers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether there is a bucket to open at all.
|
/// Whether there is a bucket to open at all.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -969,6 +986,17 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string typedPassword = string.Empty;
|
private string typedPassword = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The host showing when <see cref="TypedPassword"/> was typed, which is the only one it is for.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first —
|
||||||
|
/// and the box does not empty when it does, because it cannot tell that from a reload refilling the list.
|
||||||
|
/// So the password is checked against this at the moment it would be sent instead, and never goes to a
|
||||||
|
/// host it was not typed for.
|
||||||
|
/// </remarks>
|
||||||
|
private Guid? typedPasswordFor;
|
||||||
|
|
||||||
|
partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
|
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
|
||||||
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
|
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
|
||||||
@@ -1158,7 +1186,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
internal bool SelectedHostAsksForAPassword =>
|
internal bool SelectedHostAsksForAPassword =>
|
||||||
ShowsHostPicker
|
ShowsHostPicker
|
||||||
&& (SelectedHost is null
|
&& (SelectedHost is null
|
||||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||||
|
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword));
|
||||||
|
|
||||||
// ---- The remote pane ----
|
// ---- The remote pane ----
|
||||||
|
|
||||||
@@ -1779,8 +1808,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
|
||||||
|
|
||||||
if (!open.TryBuildConnectionRequest(
|
if (!open.TryBuildConnectionRequest(
|
||||||
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
|
row.Host, password, out var request, out var identityLabel, out var refusal))
|
||||||
{
|
{
|
||||||
Status = refusal;
|
Status = refusal;
|
||||||
return;
|
return;
|
||||||
@@ -1796,14 +1827,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await CloseSessionAsync().ConfigureAwait(true);
|
await CloseSessionAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
|
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
|
||||||
is not { } opened)
|
is not ({ } opened, { } dialled))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
session = opened;
|
session = opened;
|
||||||
TypedPassword = string.Empty;
|
TypedPassword = string.Empty;
|
||||||
MarkHostConnected(opened, request, row, identityLabel);
|
MarkHostConnected(opened, dialled, row, identityLabel);
|
||||||
|
|
||||||
reconnectTo = (RemoteKind.Host, row.EntityId);
|
reconnectTo = (RemoteKind.Host, row.EntityId);
|
||||||
|
|
||||||
@@ -1816,34 +1847,36 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the session on a host, through the relay when the host uses it, or says why not.
|
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
|
/// <returns>
|
||||||
private async Task<ISftpSession?> OpenHostSessionAsync(
|
/// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account
|
||||||
|
/// Spelunk named rather than the one the host resolved to — or null with <see cref="Status"/> saying why
|
||||||
|
/// there is none.
|
||||||
|
/// </returns>
|
||||||
|
private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync(
|
||||||
VaultViewModel open,
|
VaultViewModel open,
|
||||||
HostRowViewModel row,
|
HostRowViewModel row,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RelayRoute? relay = null;
|
IConnectionRoute? route = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Through the relay for a host set to use it, exactly as a shell to it would be. The
|
// Through the relay or Spelunk for a host set to use one, exactly as a shell to it would be.
|
||||||
// session owns the relay from here, on success and on failure alike.
|
// The session owns the route from here, on success and on failure alike.
|
||||||
relay = row.Host.RelayEnabled
|
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
|
||||||
? await open.OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return await sftp
|
var through = VaultViewModel.Through(request, route);
|
||||||
.OpenSftpAsync(relay is null ? request : request with { Proxy = relay.Proxy }, cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
when (relay?.Failure is { } failure
|
when (route?.Failure is { } failure
|
||||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||||
{
|
{
|
||||||
// See VaultViewModel.OpenThroughAsync: the relay's reason is the true one.
|
// See VaultViewModel.OpenThroughAsync: the route's reason is the true one.
|
||||||
Status = failure;
|
Status = failure;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -2256,6 +2289,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
// ---- Moving files ----
|
// ---- Moving files ----
|
||||||
|
|
||||||
|
/// <summary>What a transfer asked for while another command is still running is told.</summary>
|
||||||
|
private const string StillBusy =
|
||||||
|
"Still working on the last request. Nothing was queued — try again when it has finished.";
|
||||||
|
|
||||||
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Download()
|
private void Download()
|
||||||
@@ -2273,7 +2310,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
|
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
ClearRemoteChecks();
|
ClearRemoteChecks();
|
||||||
});
|
},
|
||||||
|
StillBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||||
@@ -2295,7 +2333,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
|
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
ClearLocalChecks();
|
ClearLocalChecks();
|
||||||
});
|
},
|
||||||
|
StillBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Unticks everything on the host's side.</summary>
|
/// <summary>Unticks everything on the host's side.</summary>
|
||||||
@@ -2360,13 +2399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
internal void QueueUploads(IReadOnlyList<string> paths) =>
|
internal void QueueUploads(IReadOnlyList<string> paths) =>
|
||||||
_ = RunAsync(
|
_ = RunAsync(
|
||||||
"Working out what to upload…",
|
"Working out what to upload…",
|
||||||
() => QueueUploadsAsync(paths, CancellationToken.None));
|
() => QueueUploadsAsync(paths, CancellationToken.None),
|
||||||
|
StillBusy);
|
||||||
|
|
||||||
/// <inheritdoc cref="QueueUploads" />
|
/// <inheritdoc cref="QueueUploads" />
|
||||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
|
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
|
||||||
_ = RunAsync(
|
_ = RunAsync(
|
||||||
"Working out what to download…",
|
"Working out what to download…",
|
||||||
() => QueueDownloadsAsync(rows, CancellationToken.None));
|
() => QueueDownloadsAsync(rows, CancellationToken.None),
|
||||||
|
StillBusy);
|
||||||
|
|
||||||
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
|
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -2547,7 +2588,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
_ = RunAsync(
|
_ = RunAsync(
|
||||||
"Working out what to upload…",
|
"Working out what to upload…",
|
||||||
() => QueueUploadsAsync(paths, CancellationToken.None));
|
() => QueueUploadsAsync(paths, CancellationToken.None),
|
||||||
|
StillBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2674,9 +2716,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
|
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
|
||||||
|
{
|
||||||
|
plan.Unsafe++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (row.IsFile)
|
if (row.IsFile)
|
||||||
{
|
{
|
||||||
plan.Downloads.Add((Path.Combine(LocalPath, row.Name), row.Entry));
|
plan.Downloads.Add((local, row.Entry));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2692,11 +2740,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
await WalkRemoteAsync(
|
await WalkRemoteAsync(
|
||||||
row.FullPath,
|
row.FullPath,
|
||||||
Path.Combine(LocalPath, row.Name),
|
local,
|
||||||
plan,
|
plan,
|
||||||
cancellationToken).ConfigureAwait(true);
|
cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnqueueDownloads(plan);
|
||||||
|
|
||||||
|
Status = Describe(plan, "download into", LocalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the directories a download plan needs here, then hands its files to the queue.</summary>
|
||||||
|
private void EnqueueDownloads(TransferPlan plan)
|
||||||
|
{
|
||||||
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
|
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -2716,8 +2772,6 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
plan.Queued++;
|
plan.Queued++;
|
||||||
}
|
}
|
||||||
|
|
||||||
Status = Describe(plan, "download into", LocalPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
|
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
|
||||||
@@ -2771,30 +2825,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (entry.Kind)
|
PlanRemoteEntry(plan, pending, entry, toDirectory);
|
||||||
{
|
|
||||||
case SftpEntryKind.File:
|
|
||||||
plan.Downloads.Add((Path.Combine(toDirectory, entry.Name), entry));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SftpEntryKind.Directory:
|
|
||||||
pending.Enqueue((entry.FullPath, Path.Combine(toDirectory, entry.Name)));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SftpEntryKind.SymbolicLink:
|
|
||||||
plan.Links++;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to
|
|
||||||
// create, and counting it as a refusal would overstate what went wrong.
|
|
||||||
plan.Skipped++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds one entry of a remote listing to a download plan.</summary>
|
||||||
|
private static void PlanRemoteEntry(
|
||||||
|
TransferPlan plan,
|
||||||
|
Queue<(string Remote, string Local)> pending,
|
||||||
|
SftpEntry entry,
|
||||||
|
string toDirectory)
|
||||||
|
{
|
||||||
|
var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory
|
||||||
|
? LocalDirectory.ChildPath(toDirectory, entry.Name)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
switch (entry.Kind)
|
||||||
|
{
|
||||||
|
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
|
||||||
|
plan.Unsafe++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SftpEntryKind.File:
|
||||||
|
plan.Downloads.Add((here!, entry));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SftpEntryKind.Directory:
|
||||||
|
pending.Enqueue((entry.FullPath, here!));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SftpEntryKind.SymbolicLink:
|
||||||
|
plan.Links++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
|
||||||
|
// counting it as a refusal would overstate what went wrong.
|
||||||
|
plan.Skipped++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>What an upload of this local file should carry to the host with it.</summary>
|
/// <summary>What an upload of this local file should carry to the host with it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -2890,6 +2962,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
|
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
|
||||||
public int Skipped { get; set; }
|
public int Skipped { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Remote names that would land outside the directory they were meant for.</summary>
|
||||||
|
/// <remarks>See <see cref="LocalDirectory.ChildPath"/>.</remarks>
|
||||||
|
public int Unsafe { get; set; }
|
||||||
|
|
||||||
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
|
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
|
||||||
public bool Truncated { get; set; }
|
public bool Truncated { get; set; }
|
||||||
|
|
||||||
@@ -2934,6 +3010,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
said += $" {other} not a file.";
|
said += $" {other} not a file.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plan.Unsafe > 0)
|
||||||
|
{
|
||||||
|
var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were";
|
||||||
|
said += $" {named} left on the host.";
|
||||||
|
}
|
||||||
|
|
||||||
if (plan.Truncated)
|
if (plan.Truncated)
|
||||||
{
|
{
|
||||||
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
|
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
|
||||||
@@ -3159,13 +3241,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
$"Fetching {row.Name}…",
|
$"Fetching {row.Name}…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
var copy = Path.Combine(
|
var folder = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
"dodossh-edits",
|
"dodossh-edits",
|
||||||
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
|
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
|
||||||
row.Name);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(copy)!);
|
if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy)
|
||||||
|
{
|
||||||
|
Status = $"{row.Name} is not a name this machine can open as a file.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
|
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -3899,7 +3986,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
|
/// <remarks>
|
||||||
|
/// Only the picker the list feeds. Refilling both on either list's change made every reload refill the host
|
||||||
|
/// picker once for the hosts and again for the buckets.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (vault is { } open && ReferenceEquals(sender, open.ObjectStores))
|
||||||
|
{
|
||||||
|
RefreshPickers(hosts: false, buckets: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RefreshPickers(hosts: true, buckets: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rebuilds both pickers from the vault's lists.
|
/// Rebuilds both pickers from the vault's lists.
|
||||||
@@ -3917,33 +4018,39 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// connected to.
|
/// connected to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RefreshHosts()
|
private void RefreshHosts() => RefreshPickers(hosts: true, buckets: true);
|
||||||
|
|
||||||
|
private void RefreshPickers(bool hosts, bool buckets)
|
||||||
{
|
{
|
||||||
var host = SelectedHost?.EntityId;
|
refreshingPickers = true;
|
||||||
var bucket = SelectedBucket?.EntityId;
|
|
||||||
|
|
||||||
Hosts.Clear();
|
try
|
||||||
|
|
||||||
if (vault is not { } open)
|
|
||||||
{
|
{
|
||||||
return;
|
if (vault is not { } open)
|
||||||
}
|
{
|
||||||
|
Hosts.ReplaceAll([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var row in open.Hosts)
|
if (hosts)
|
||||||
|
{
|
||||||
|
Hosts.ReplaceAll(open.Hosts);
|
||||||
|
|
||||||
|
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buckets)
|
||||||
|
{
|
||||||
|
Buckets.ReplaceAll(open.ObjectStores);
|
||||||
|
|
||||||
|
SelectedBucket =
|
||||||
|
Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
{
|
{
|
||||||
Hosts.Add(row);
|
refreshingPickers = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
|
|
||||||
|
|
||||||
Buckets.Clear();
|
|
||||||
|
|
||||||
foreach (var row in open.ObjectStores)
|
|
||||||
{
|
|
||||||
Buckets.Add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||||
@@ -4070,11 +4177,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
||||||
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
||||||
/// pane permanently disabled.
|
/// pane permanently disabled.
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="refusedWhileBusy"/> is for the commands somebody can fire while another is running —
|
||||||
|
/// a drop, or the download and upload buttons pressed twice — which used to vanish without a word: the
|
||||||
|
/// files were not queued and nothing said so. A command given one says why it did nothing instead.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
private async Task RunAsync(string busyMessage, Func<Task> work, string? refusedWhileBusy = null)
|
||||||
{
|
{
|
||||||
if (IsBusy)
|
if (IsBusy)
|
||||||
{
|
{
|
||||||
|
if (refusedWhileBusy is not null)
|
||||||
|
{
|
||||||
|
Status = refusedWhileBusy;
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4103,8 +4220,30 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await SettleRemoteListingAsync().ConfigureAwait(true);
|
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
partial void OnSelectedHostChanged(HostRowViewModel? value)
|
||||||
|
{
|
||||||
|
if (!refreshingPickers)
|
||||||
|
{
|
||||||
|
chosenHost = value?.EntityId;
|
||||||
|
|
||||||
|
// A person moving to another host gets an empty box rather than one still showing a password
|
||||||
|
// that, by typedPasswordFor, would no longer be sent.
|
||||||
|
if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor)
|
||||||
|
{
|
||||||
|
TypedPassword = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
|
||||||
|
{
|
||||||
|
if (!refreshingPickers)
|
||||||
|
{
|
||||||
|
chosenBucket = value?.EntityId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
||||||
|
|||||||
@@ -0,0 +1,543 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reaching a host through Spelunk: the editor's half, the connect path's half, and a link from Spelunk.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A file of its own because it is one feature and this class is long enough without it. Everything here
|
||||||
|
/// follows the relay, which is the other route a host can take: a tick box in the editor, a route opened by
|
||||||
|
/// the connect that will use it, no falling back to a direct dial, and a stated failure when the route
|
||||||
|
/// cannot be opened. See <see cref="HostSecret.Spelunk"/> and <see cref="SpelunkRoute"/>.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class VaultViewModel
|
||||||
|
{
|
||||||
|
/// <summary>The tag every host made from a Spelunk link wears, and what a link looks for.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Matched without regard to case, as the editor matches every tag name — see <c>AddEditorTagAsync</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal const string SpelunkTag = "spelunk";
|
||||||
|
|
||||||
|
/// <summary>What the status bar and the log name as the identity of a Spelunk sign-in.</summary>
|
||||||
|
private const string SpelunkSignInIdentity = "Spelunk sign-in";
|
||||||
|
|
||||||
|
/// <summary>What opens a tunnel when no caller supplied something else.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Static and shared for the reason <see cref="DefaultReachability"/> is: it holds nothing but a
|
||||||
|
/// transport, and every tunnel is a fresh ticket and a fresh socket. Optional in the constructor so both
|
||||||
|
/// heads and every existing test go on meaning what they did, and a test that wants to see what was
|
||||||
|
/// opened passes a substitute.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly ISpelunkTunnel DefaultSpelunk = new SpelunkTunnel();
|
||||||
|
|
||||||
|
/// <summary>Whether the host being edited is reached through Spelunk.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Ticking it unticks the relay and the other way round, rather than letting both be ticked and refusing
|
||||||
|
/// the save: a host has one route, and <see cref="HostSecret.TryValidate"/> would only catch after the
|
||||||
|
/// fact what these two boxes can simply not express.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
|
||||||
|
private bool editorThroughSpelunk;
|
||||||
|
|
||||||
|
/// <summary>Whether Spelunk signs the user in to the host being edited, so it needs no login of its own.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Inside the Spelunk card, and meaningful only while that is ticked. See
|
||||||
|
/// <see cref="SpelunkRoute.SignInThroughSpelunk"/>.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
|
||||||
|
private bool editorSpelunkSignIn;
|
||||||
|
|
||||||
|
/// <summary>The Spelunk API address in the editor, as typed.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string editorSpelunkApi = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The Spelunk server id in the editor, as typed.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string editorSpelunkServerId = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>What the editor offers as the Spelunk API key: saved Spelunk keys, then credentials.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// No "none" entry: a route with no key cannot be opened, and nothing selected is how a host made from a
|
||||||
|
/// link says it has not been chosen yet. A key the host names that has since been deleted keeps a
|
||||||
|
/// placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
|
||||||
|
/// </remarks>
|
||||||
|
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The credential chosen to hold the Spelunk API key.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private AuthenticationChoice? editorSelectedSpelunkKey;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The two controls are disabled rather than hidden while it is false, with a sentence saying why, so a
|
||||||
|
/// login somebody had chosen is still there to see — and comes back as it was if the box is unticked
|
||||||
|
/// before saving.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool EditorAsksForLogin => !(EditorThroughSpelunk && EditorSpelunkSignIn);
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DefaultSpelunk" />
|
||||||
|
private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk;
|
||||||
|
|
||||||
|
partial void OnEditorThroughSpelunkChanged(bool value)
|
||||||
|
{
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
EditorRelayEnabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnEditorRelayEnabledChanged(bool value)
|
||||||
|
{
|
||||||
|
if (value)
|
||||||
|
{
|
||||||
|
EditorThroughSpelunk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the route a host is reached by, or null for a host that is dialled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The one place that decides, for both transports: a shell comes through here from
|
||||||
|
/// <see cref="OpenThroughAsync"/>, and file transfer opens its own connection and comes through here from
|
||||||
|
/// the transfers screen — or that screen would fail for exactly the hosts a route exists for.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A host naming a route that cannot be opened is a refusal, never a quiet direct dial. See
|
||||||
|
/// <see cref="OpenRelayAsync"/> for the relay's half of that argument; Spelunk's is stronger still,
|
||||||
|
/// because a Spelunk host's address may be nothing that answers at all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The request a route is used for is made with <see cref="Through"/>, which is where a Spelunk sign-in's
|
||||||
|
/// key and account replace the ones the host resolved to.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
|
||||||
|
if (row.Host.Spelunk is { } route)
|
||||||
|
{
|
||||||
|
return await OpenSpelunkTunnelAsync(row, route, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return row.Host.RelayEnabled
|
||||||
|
? await OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The request to connect with once a route is open: through its proxy, and for a Spelunk sign-in, with
|
||||||
|
/// the key the tunnel was opened with and the account Spelunk named.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both transports make their request through this, so a sign-in cannot reach one of them with the
|
||||||
|
/// stand-in credential <see cref="TryBuildAuthentication"/> gives it — which the SSH layer would refuse
|
||||||
|
/// before dialling anyway. See <see cref="SpelunkSignInPending"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The host and port are left alone. The host key is pinned under them as for every host, and Spelunk's
|
||||||
|
/// SSH server presents the server's real host key, so a sign-in and a plain tunnel to one server are
|
||||||
|
/// checked against the same pin.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static SshConnectionRequest Through(SshConnectionRequest request, IConnectionRoute? route)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
|
return route switch
|
||||||
|
{
|
||||||
|
null => request,
|
||||||
|
SpelunkTunnelRoute { SignIn: { } signIn, User: { } user } => request with
|
||||||
|
{
|
||||||
|
Proxy = route.Proxy,
|
||||||
|
Username = user,
|
||||||
|
Credential = signIn.Credential,
|
||||||
|
},
|
||||||
|
_ => request with { Proxy = route.Proxy },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a Spelunk tunnel to a host, with the key it names.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
|
||||||
|
/// path uses, and goes only to the API address the host names — and for a saved Spelunk key, only when
|
||||||
|
/// that is the address it was saved for. See <see cref="SpelunkApiKeyFor"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
||||||
|
/// server's own loopback, which is where the SSH server listens whatever address the world sees.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A host Spelunk signs in to gets a new key pair here, every time.</b> It is made in memory for this
|
||||||
|
/// one tunnel, its public half goes with the ticket request, and the route carries it to the connection —
|
||||||
|
/// see <see cref="SpelunkSignIn"/>. A retry after a host key question, or a reconnect, comes back through
|
||||||
|
/// here and makes another.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<IConnectionRoute> OpenSpelunkTunnelAsync(
|
||||||
|
HostRowViewModel row,
|
||||||
|
SpelunkRoute route,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var apiKey = SpelunkApiKeyFor(row, route);
|
||||||
|
|
||||||
|
return await Spelunk
|
||||||
|
.OpenAsync(
|
||||||
|
route.ApiBase,
|
||||||
|
route.ServerId,
|
||||||
|
apiKey,
|
||||||
|
row.Resolved.Port.Value,
|
||||||
|
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The work the link started, connection attempt included, so a caller that wants to can wait for it.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A host for that server in the personal vault, tagged <see cref="SpelunkTag"/>, is connected to</b>,
|
||||||
|
/// exactly as a tap on its row would. Those are the hosts links make, and the only ones a link looks for:
|
||||||
|
/// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
|
||||||
|
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
|
||||||
|
/// SSH key or password — with the key saved for that Spelunk under Settings → Security, or else the key
|
||||||
|
/// credential a saved host already sends there. It is saved the ordinary way and syncs like any host.
|
||||||
|
/// Nothing leaks by this: the key goes where the user already said it goes, and the server is one that
|
||||||
|
/// key's own organisation holds or Spelunk refuses it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The first link from a Spelunk with no key opens the editor instead, and nothing more.</b> Anything
|
||||||
|
/// on this machine can make a link, so it must never be able to send an API key to an address the user
|
||||||
|
/// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
|
||||||
|
/// vault — and the user chooses the key and saves; every later link from that Spelunk connects straight
|
||||||
|
/// away.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(link);
|
||||||
|
|
||||||
|
var personal = PersonalVaultId();
|
||||||
|
|
||||||
|
if (personal is { } vaultId
|
||||||
|
&& Hosts.FirstOrDefault(row =>
|
||||||
|
row.VaultId == vaultId
|
||||||
|
&& row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true
|
||||||
|
&& WearsSpelunkTag(row.Host)) is { } saved)
|
||||||
|
{
|
||||||
|
await ConnectToRowCommand.ExecuteAsync(saved).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
|
||||||
|
|
||||||
|
if (personal is { } into && knownKey is { } key)
|
||||||
|
{
|
||||||
|
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The personal vault, when this session can read and write it.</summary>
|
||||||
|
private Guid? PersonalVaultId() =>
|
||||||
|
session.ReadableVaults.FirstOrDefault(vault => vault.IsPersonal && vault.CanWrite)?.VaultId;
|
||||||
|
|
||||||
|
/// <summary>Whether a host wears a tag called <see cref="SpelunkTag"/>, from whichever vault.</summary>
|
||||||
|
private bool WearsSpelunkTag(HostSecret host) =>
|
||||||
|
host.TagIds.Any(id => tagsById.TryGetValue(id, out var tag) && IsSpelunkTag(tag));
|
||||||
|
|
||||||
|
private static bool IsSpelunkTag(TagSecret tag) =>
|
||||||
|
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The key the user has already agreed may go to this Spelunk, or null when there is none.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A Spelunk key saved for that address first — it is the one thing that says so in as many words — and
|
||||||
|
/// otherwise the key a saved host already sends there, which is how the answer was reached before saved
|
||||||
|
/// keys existed and is still true of every host set up that way.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The personal vault's first, so the answer does not depend on the order vaults happen to load in; and
|
||||||
|
/// only a key still in the keychain, because a dangling one would make a host that cannot connect.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
|
||||||
|
SavedSpelunkKeyFor(apiBase, personal)?.EntityId
|
||||||
|
?? Hosts
|
||||||
|
.OrderByDescending(row => row.VaultId == personal)
|
||||||
|
.Select(row => row.Host.Spelunk)
|
||||||
|
.Where(route => route is not null && string.Equals(route.ApiBase, apiBase, StringComparison.Ordinal))
|
||||||
|
.Select(route => (Guid?)route!.ApiKeyCredentialId)
|
||||||
|
.FirstOrDefault(id =>
|
||||||
|
Credentials.Any(credential => credential.EntityId == id)
|
||||||
|
|| SpelunkKeys.Any(key => key.EntityId == id && string.Equals(key.ApiBase, apiBase, StringComparison.Ordinal)));
|
||||||
|
|
||||||
|
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
|
||||||
|
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
|
||||||
|
{
|
||||||
|
Label = link.Name,
|
||||||
|
Hostname = link.Hostname,
|
||||||
|
Port = HostSecret.DefaultPort,
|
||||||
|
TagIds = tags,
|
||||||
|
Spelunk = new SpelunkRoute
|
||||||
|
{
|
||||||
|
ApiBase = link.ApiBase,
|
||||||
|
ServerId = link.ServerId,
|
||||||
|
ApiKeyCredentialId = apiKeyCredentialId,
|
||||||
|
SignInThroughSpelunk = true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
|
||||||
|
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId)
|
||||||
|
{
|
||||||
|
if (IsBusy)
|
||||||
|
{
|
||||||
|
// RunAsync would drop it without a word, and the user pressed a button somewhere else to get here.
|
||||||
|
Status = $"Busy — open {link.Name} from Spelunk again in a moment.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Guid? hostId = null;
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
$"Adding {link.Name} from Spelunk…",
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
hostId = await session.Hosts
|
||||||
|
.CreateAsync(
|
||||||
|
vaultId,
|
||||||
|
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// A failure has said so on the status line already.
|
||||||
|
if (hostId is not { } id || Hosts.FirstOrDefault(row => row.EntityId == id) is not { } row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Status = $"Added {link.Name} from Spelunk at {link.ApiBase}.";
|
||||||
|
|
||||||
|
await ConnectToRowCommand.ExecuteAsync(row).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// After the connect rather than before it: the host is saved either way, and the terminal is what the
|
||||||
|
// user is waiting for. As SaveHostAsync does, it goes to the server now rather than at the next tick.
|
||||||
|
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is
|
||||||
|
/// made or found afterwards and added to the host being edited; a tag made for a host that is then
|
||||||
|
/// cancelled stays, as the editor's own new-tag box leaves it.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey)
|
||||||
|
{
|
||||||
|
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
|
||||||
|
// is not worth somebody's unsaved host.
|
||||||
|
if (AHostEditorIsInTheWay())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NewHost();
|
||||||
|
|
||||||
|
if (personal is { } vaultId
|
||||||
|
&& EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId) is { } choice)
|
||||||
|
{
|
||||||
|
EditorSelectedVault = choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorLabel = link.Name;
|
||||||
|
EditorHostname = link.Hostname;
|
||||||
|
EditorPort = HostSecret.DefaultPort;
|
||||||
|
EditorThroughSpelunk = true;
|
||||||
|
EditorSpelunkSignIn = true;
|
||||||
|
EditorSpelunkApi = link.ApiBase;
|
||||||
|
EditorSpelunkServerId = link.ServerId.ToString();
|
||||||
|
|
||||||
|
BuildSpelunkKeyChoices(knownKey);
|
||||||
|
|
||||||
|
IsHostPaneOpen = true;
|
||||||
|
|
||||||
|
var said = EditorSelectedSpelunkKey is null
|
||||||
|
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose your Spelunk API "
|
||||||
|
+ "key and save — Spelunk signs you in, so no SSH key is needed, and later links from this "
|
||||||
|
+ "Spelunk connect straight away. Saving the key under Settings → Security does the same."
|
||||||
|
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
|
||||||
|
|
||||||
|
Status = said;
|
||||||
|
|
||||||
|
if (personal is { } into)
|
||||||
|
{
|
||||||
|
await TagHostFromLinkAsync(into, said).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds the <see cref="SpelunkTag"/> tag to the host a link opened the editor on.</summary>
|
||||||
|
/// <param name="vaultId">The personal vault, where the host is to be saved and the tag is.</param>
|
||||||
|
/// <param name="said">What the status line says about the link, put back after the reload.</param>
|
||||||
|
private async Task TagHostFromLinkAsync(Guid vaultId, string said)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// Only onto the host this link opened: the user may have cancelled it, or moved on to another, in
|
||||||
|
// the moment the tag took.
|
||||||
|
if (!IsEditing || editingEntityId is not null || editingHostVaultId != vaultId)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorTagIds = TagSet.Create([.. editorTagIds, tagId]);
|
||||||
|
|
||||||
|
if (!tagsById.ContainsKey(tagId))
|
||||||
|
{
|
||||||
|
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
BuildTagChoices();
|
||||||
|
Status = said;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// The host can still be saved; it is the tag that could not be made, and a later link would then
|
||||||
|
// not find it. Said, rather than failing the whole link over it.
|
||||||
|
Status = $"{said} (The '{SpelunkTag}' tag could not be added: {exception.Message})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The <see cref="SpelunkTag"/> tag in a vault, made there if it has none.</summary>
|
||||||
|
private async Task<Guid> EnsureSpelunkTagAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var listing = await session.Tags.ListAsync(vaultId, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (listing.Items
|
||||||
|
.Where(tag => IsSpelunkTag(tag.Secret))
|
||||||
|
.OrderBy(tag => tag.EntityId)
|
||||||
|
.FirstOrDefault() is { } existing)
|
||||||
|
{
|
||||||
|
return existing.EntityId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await session.Tags
|
||||||
|
.CreateAsync(vaultId, new TagSecret { Label = SpelunkTag }, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential
|
||||||
|
/// holding its Spelunk API key.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
|
||||||
|
/// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk
|
||||||
|
/// keys are named by the host itself and never inherited, so the stored route is the whole answer there.
|
||||||
|
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
||||||
|
(!row.Host.SignsInThroughSpelunk
|
||||||
|
&& row.Resolved.Binding is { } binding
|
||||||
|
&& binding.Kind == kind
|
||||||
|
&& binding.EntityId == entityId)
|
||||||
|
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
|
||||||
|
|
||||||
|
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
||||||
|
private void LoadEditorSpelunk(SpelunkRoute? route)
|
||||||
|
{
|
||||||
|
EditorThroughSpelunk = route is not null;
|
||||||
|
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
|
||||||
|
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
||||||
|
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
||||||
|
|
||||||
|
BuildSpelunkKeyChoices(route?.ApiKeyCredentialId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Saved Spelunk keys first, named for where they go, because that is the choice the Security page made
|
||||||
|
/// for exactly this; then every credential, for a key kept as a password before saved keys existed.
|
||||||
|
/// </remarks>
|
||||||
|
private void BuildSpelunkKeyChoices(Guid? selectedId)
|
||||||
|
{
|
||||||
|
EditorSpelunkKeyChoices.Clear();
|
||||||
|
|
||||||
|
foreach (var key in SpelunkKeys)
|
||||||
|
{
|
||||||
|
EditorSpelunkKeyChoices.Add(new AuthenticationChoice(
|
||||||
|
AuthenticationKind.Credential, key.EntityId, key.Label, $"Spelunk key · {key.ApiBase}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var credential in Credentials)
|
||||||
|
{
|
||||||
|
EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedId is { } missing && EditorSpelunkKeyChoices.All(choice => choice.EntityId != missing))
|
||||||
|
{
|
||||||
|
EditorSpelunkKeyChoices.Add(AuthenticationChoice.Missing(AuthenticationKind.Credential, missing));
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The editor's route, or null when the box is not ticked.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What cannot be read is written as empty rather than left out, so that
|
||||||
|
/// <see cref="HostSecret.TryValidate"/> says what is wrong with it — "needs the id of its Spelunk server"
|
||||||
|
/// — instead of the save quietly producing a host that is dialled directly. The address is normalised
|
||||||
|
/// here when it can be, so <c>https://api.example.com/</c> saves as the same Spelunk as the link's.
|
||||||
|
/// </remarks>
|
||||||
|
private SpelunkRoute? BuildSpelunkRoute()
|
||||||
|
{
|
||||||
|
if (!EditorThroughSpelunk)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpelunkRoute
|
||||||
|
{
|
||||||
|
ApiBase = SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var normalized, out _)
|
||||||
|
? normalized
|
||||||
|
: EditorSpelunkApi.Trim(),
|
||||||
|
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
||||||
|
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
|
||||||
|
SignInThroughSpelunk = EditorSpelunkSignIn,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,374 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
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>One saved Spelunk key, as a row on the Security page.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Carries the decrypted key so the connect path needs no second decryption, and — as the credential row does
|
||||||
|
/// — exposes nothing of it to the view: the XAML binds the name, the address and a count.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SpelunkKeyRowViewModel(VaultItem<SpelunkKeySecret> key, Guid vaultId, string vaultName)
|
||||||
|
{
|
||||||
|
internal Guid EntityId => key.EntityId;
|
||||||
|
|
||||||
|
internal Guid VaultId { get; } = vaultId;
|
||||||
|
|
||||||
|
internal string VaultName { get; } = vaultName;
|
||||||
|
|
||||||
|
internal SpelunkKeySecret Key => key.Secret;
|
||||||
|
|
||||||
|
internal string Label => key.Secret.Label;
|
||||||
|
|
||||||
|
/// <summary>The one address this key is ever sent to.</summary>
|
||||||
|
internal string ApiBase => key.Secret.ApiBase;
|
||||||
|
|
||||||
|
/// <summary>The vault's name, when there is more than one to tell apart; empty otherwise.</summary>
|
||||||
|
internal string VaultBadge { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>How many hosts reach Spelunk with this key, in words.</summary>
|
||||||
|
internal string UsedBy { get; init; } = string.Empty;
|
||||||
|
|
||||||
|
internal bool IsReadOnly => key.IsReadOnly;
|
||||||
|
|
||||||
|
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spelunk keys: filling one in on the Security page, and what a saved one lets a host and a link do.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A saved key says where it may go.</b> It is an API key and the one Spelunk address it belongs to, kept
|
||||||
|
/// in the keychain and synced like any secret. A host can name it where it would name a credential, and it is
|
||||||
|
/// then sent to that address and never to another — see <see cref="SpelunkApiKeyFor"/>. A link from that
|
||||||
|
/// Spelunk connects straight away, because saving the key is the user saying so — see
|
||||||
|
/// <see cref="KnownSpelunkKey"/>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The form saves into the personal vault, and a second key for an address the personal vault already has a
|
||||||
|
/// key for replaces that key rather than adding a rival: one Spelunk, one key, which is also what makes the
|
||||||
|
/// answer to "which key does a link use?" not depend on anything the user cannot see.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class VaultViewModel
|
||||||
|
{
|
||||||
|
/// <summary>Every Spelunk key this session can read, the personal vault's first.</summary>
|
||||||
|
internal ObservableCollection<SpelunkKeyRowViewModel> SpelunkKeys { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether there is any saved key to list.</summary>
|
||||||
|
internal bool HasSpelunkKeys => SpelunkKeys.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string spelunkKeyFormApi = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The API key in the Security page's form, as pasted. Cleared the moment it is saved.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string spelunkKeyFormKey = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>What the form has to say about the last save, or empty.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Beside the form rather than only on the status line, because the Security page is a long way from the
|
||||||
|
/// status bar and a refusal nobody sees reads as a button that does nothing.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string spelunkKeyFormNote = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether <see cref="SpelunkKeyFormNote"/> has anything in it.</summary>
|
||||||
|
internal bool HasSpelunkKeyFormNote => SpelunkKeyFormNote.Length > 0;
|
||||||
|
|
||||||
|
partial void OnSpelunkKeyFormNoteChanged(string value) => OnPropertyChanged(nameof(HasSpelunkKeyFormNote));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Picks the saved key for the address being typed, when nothing is chosen yet.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only into an empty choice: a key somebody picked is theirs, and replacing it because they edited the
|
||||||
|
/// address would be choosing for them.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnEditorSpelunkApiChanged(string value)
|
||||||
|
{
|
||||||
|
if (EditorSelectedSpelunkKey is not null
|
||||||
|
|| !SpelunkRoute.TryNormalizeApiBase(value, out var apiBase, out _)
|
||||||
|
|| SavedSpelunkKeyFor(apiBase, PersonalVaultId()) is not { } saved)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == saved.EntityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Saves the key in the form for the address in the form.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SaveSpelunkKeyAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!TryReadSpelunkKeyForm(out var vaultId, out var existing, out var key))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiBase = key.ApiBase;
|
||||||
|
|
||||||
|
SpelunkKeyFormNote = string.Empty;
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
"Saving the Spelunk key…",
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
await session.SpelunkKeys
|
||||||
|
.UpdateAsync(vaultId, existing.EntityId, key, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await session.SpelunkKeys.CreateAsync(vaultId, key, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The key leaves the form as soon as it is in the keychain; the address stays, as a reminder
|
||||||
|
// of what was just saved.
|
||||||
|
SpelunkKeyFormKey = string.Empty;
|
||||||
|
SpelunkKeyFormApi = apiBase;
|
||||||
|
|
||||||
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
var said = existing is null
|
||||||
|
? $"Saved the Spelunk key for {apiBase}. Links from that Spelunk now connect straight away."
|
||||||
|
: $"Replaced the Spelunk key for {apiBase}. Every host using it connects with the new one.";
|
||||||
|
|
||||||
|
Status = connection() is null ? $"{said} It will sync when you are online." : said;
|
||||||
|
SpelunkKeyFormNote = said;
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the form into the key it would save, and where; or says on the form why it cannot.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="vaultId">The vault the key goes into: the personal one, when there is one.</param>
|
||||||
|
/// <param name="existing">The key already saved there for that address, which this one replaces.</param>
|
||||||
|
/// <param name="key">The key to save, keeping the existing one's name.</param>
|
||||||
|
private bool TryReadSpelunkKeyForm(
|
||||||
|
out Guid vaultId,
|
||||||
|
out SpelunkKeyRowViewModel? existing,
|
||||||
|
[NotNullWhen(true)] out SpelunkKeySecret? key)
|
||||||
|
{
|
||||||
|
vaultId = Guid.Empty;
|
||||||
|
existing = null;
|
||||||
|
key = null;
|
||||||
|
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(SpelunkKeyFormApi, out var apiBase, out var reason))
|
||||||
|
{
|
||||||
|
SpelunkKeyFormNote = reason;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((PersonalVaultId() ?? WritableActiveVault()) is not { } target)
|
||||||
|
{
|
||||||
|
SpelunkKeyFormNote = "There is no vault here this account can write to, so the key cannot be saved.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
vaultId = target;
|
||||||
|
existing = SpelunkKeys.FirstOrDefault(row =>
|
||||||
|
row.VaultId == target && string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var candidate = new SpelunkKeySecret
|
||||||
|
{
|
||||||
|
Label = existing?.Label ?? SpelunkKeyLabel(apiBase),
|
||||||
|
ApiBase = apiBase,
|
||||||
|
ApiKey = SpelunkKeyFormKey.Trim(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!candidate.TryValidate(out reason))
|
||||||
|
{
|
||||||
|
SpelunkKeyFormNote = reason;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
key = candidate;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Asks whether a saved Spelunk key should go.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void DeleteSpelunkKeyRow(SpelunkKeyRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingDeletion = new DeletionRequest(
|
||||||
|
DeletionTarget.SpelunkKey,
|
||||||
|
row.EntityId,
|
||||||
|
$"Delete the Spelunk key for {row.ApiBase}?",
|
||||||
|
HowFarADeletionGoes("The key"),
|
||||||
|
HostsReachingSpelunkWith(row.EntityId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Queues a tombstone for the Spelunk key that was agreed to.</summary>
|
||||||
|
private async Task DeleteSpelunkKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (SpelunkKeys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||||
|
{
|
||||||
|
Status = "That Spelunk key is no longer here, so nothing was deleted.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await RunAsync(
|
||||||
|
"Deleting…",
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
await session.SpelunkKeys
|
||||||
|
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
Status = $"Deleted the Spelunk key for {row.ApiBase}.";
|
||||||
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <returns>How many Spelunk keys would not decrypt.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Read after the hosts, because each row says how many of them use it.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<int> ReloadSpelunkKeysAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var unreadable = 0;
|
||||||
|
var rows = new List<SpelunkKeyRowViewModel>();
|
||||||
|
|
||||||
|
var readableVaults = session.ReadableVaults.ToList();
|
||||||
|
var several = readableVaults.Count > 1;
|
||||||
|
|
||||||
|
foreach (var vault in readableVaults)
|
||||||
|
{
|
||||||
|
var listing = await session.SpelunkKeys
|
||||||
|
.ListAsync(vault.VaultId, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
unreadable += listing.Unreadable;
|
||||||
|
|
||||||
|
rows.AddRange(listing.Items.Select(item => new SpelunkKeyRowViewModel(item, vault.VaultId, vault.Name)
|
||||||
|
{
|
||||||
|
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||||||
|
UsedBy = UsedBySummary(Hosts.Count(host => host.Host.Spelunk?.ApiKeyCredentialId == item.EntityId)),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
var personal = PersonalVaultId();
|
||||||
|
|
||||||
|
SpelunkKeys.Clear();
|
||||||
|
|
||||||
|
foreach (var row in rows
|
||||||
|
.OrderByDescending(row => row.VaultId == personal)
|
||||||
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||||
|
.ThenBy(row => row.ApiBase, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
SpelunkKeys.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasSpelunkKeys));
|
||||||
|
|
||||||
|
return unreadable;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The key to send to a host's Spelunk: a saved Spelunk key's, or a credential's password.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A saved Spelunk key goes to its own address or nowhere.</b> A host naming one with a different
|
||||||
|
/// address — edited by hand, or merged from two devices — is refused in words rather than sending the key
|
||||||
|
/// to a Spelunk it was never saved for. A credential carries no address, so its password goes where the
|
||||||
|
/// host says, as it always has.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Either can be gone, deleted on another machine, and that is refused exactly as a missing SSH credential
|
||||||
|
/// is — see <see cref="TryBuildAuthentication"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="SpelunkTunnelException">The key cannot be sent to this host's Spelunk.</exception>
|
||||||
|
private string SpelunkApiKeyFor(HostRowViewModel row, SpelunkRoute route)
|
||||||
|
{
|
||||||
|
if (SpelunkKeys.FirstOrDefault(key => key.EntityId == route.ApiKeyCredentialId) is { } saved)
|
||||||
|
{
|
||||||
|
if (!string.Equals(saved.ApiBase, route.ApiBase, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
$"'{row.Label}' is reached through {route.ApiBase} with the Spelunk key saved for "
|
||||||
|
+ $"{saved.ApiBase}, and that key is never sent anywhere else. Edit the host to choose "
|
||||||
|
+ "the key for its own Spelunk.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return saved.Key.ApiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is { } credential)
|
||||||
|
{
|
||||||
|
return credential.Credential.Password;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
$"'{row.Label}' is reached through Spelunk with an API key that is not in this keychain any more. "
|
||||||
|
+ "Edit the host to choose another one, or save the key under Settings → Security.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The saved key for a Spelunk, the given vault's first, or null when none is saved.</summary>
|
||||||
|
private SpelunkKeyRowViewModel? SavedSpelunkKeyFor(string apiBase, Guid? preferredVault) =>
|
||||||
|
SpelunkKeys
|
||||||
|
.Where(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal))
|
||||||
|
.OrderByDescending(row => row.VaultId == preferredVault)
|
||||||
|
.FirstOrDefault();
|
||||||
|
|
||||||
|
/// <summary>The active vault when this session may write to it, for a session with no personal vault.</summary>
|
||||||
|
private Guid? WritableActiveVault() =>
|
||||||
|
session.ReadableVaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId && vault.CanWrite)
|
||||||
|
?.VaultId;
|
||||||
|
|
||||||
|
/// <summary>The name a key saved from the form is given: where it goes.</summary>
|
||||||
|
private static string SpelunkKeyLabel(string apiBase) =>
|
||||||
|
Uri.TryCreate(apiBase, UriKind.Absolute, out var uri) ? $"Spelunk at {uri.Authority}" : "Spelunk";
|
||||||
|
|
||||||
|
private static string UsedBySummary(int hosts) => hosts switch
|
||||||
|
{
|
||||||
|
0 => "No host uses it yet",
|
||||||
|
1 => "1 host uses it",
|
||||||
|
_ => $"{hosts} hosts use it",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>What the hosts that reach Spelunk with a key would be left with, or nothing when none do.</summary>
|
||||||
|
private string HostsReachingSpelunkWith(Guid entityId)
|
||||||
|
{
|
||||||
|
var named = Hosts
|
||||||
|
.Where(row => row.Host.Spelunk?.ApiKeyCredentialId == entityId)
|
||||||
|
.Select(row => row.Label)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
if (named.Length == 0)
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = named.Length <= 3
|
||||||
|
? string.Join(", ", named)
|
||||||
|
: $"{string.Join(", ", named.Take(3))} and {named.Length - 3} more";
|
||||||
|
|
||||||
|
return named.Length == 1
|
||||||
|
? $"{list} reaches Spelunk with it, and will refuse to connect until it names another key."
|
||||||
|
: $"{named.Length} hosts reach Spelunk with it — {list} — and will refuse to connect until they "
|
||||||
|
+ "name another key.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1343,6 +1343,9 @@ internal enum DeletionTarget
|
|||||||
|
|
||||||
/// <summary>A tag, from the vault screen.</summary>
|
/// <summary>A tag, from the vault screen.</summary>
|
||||||
Tag,
|
Tag,
|
||||||
|
|
||||||
|
/// <summary>A saved Spelunk key, from the Security page.</summary>
|
||||||
|
SpelunkKey,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1482,7 +1485,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
ConnectionRecorder? connectionLog = null,
|
ConnectionRecorder? connectionLog = null,
|
||||||
VaultVisibility? visibility = null,
|
VaultVisibility? visibility = null,
|
||||||
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
ISshReachabilityProbe? reachability = null,
|
||||||
|
ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1659,7 +1663,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// host key list checks itself against, so a filter applied here would change what the application can
|
/// host key list checks itself against, so a filter applied here would change what the application can
|
||||||
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
|
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||||
|
|
||||||
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
|
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1673,7 +1677,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
|
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
internal ReplaceableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||||||
|
|
||||||
/// <summary>Whether the grid has anything to draw.</summary>
|
/// <summary>Whether the grid has anything to draw.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1887,7 +1891,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// groupless keychain is one flat grid and nothing above it.
|
/// groupless keychain is one flat grid and nothing above it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = [];
|
internal ReplaceableCollection<HostSectionViewModel> HostSections { get; } = [];
|
||||||
|
|
||||||
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
|
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1999,7 +2003,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
||||||
|
|
||||||
/// <summary>The buckets to show, unpushed local state included.</summary>
|
/// <summary>The buckets to show, unpushed local state included.</summary>
|
||||||
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
internal ReplaceableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
||||||
|
|
||||||
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2400,6 +2404,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
internal bool HasVaultItems => VaultItems.Count > 0;
|
internal bool HasVaultItems => VaultItems.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Whether any row in the keychain table names a vault, which is whether its VAULT column has a heading.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Read off the rows rather than off how many vaults there are, because a row's badge is what the column
|
||||||
|
/// holds: the two agree today — see <see cref="VaultItemRowViewModel.VaultBadge"/> — and if they ever stop
|
||||||
|
/// agreeing, a heading over nothing is the thing this exists to avoid.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool ShowsVaultColumn => VaultItems.Any(row => row.HasVaultBadge);
|
||||||
|
|
||||||
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -4307,10 +4319,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
|
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
|
||||||
/// every host under a group that binds a key, and the box would do nothing.
|
/// every host under a group that binds a key, and the box would do nothing.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// False for a host Spelunk signs in to, whatever it resolves to: there is nothing to type, and a box —
|
||||||
|
/// or on the phone, a sheet — asking for a password there would be a question with no use for its answer.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool SelectedHostAsksForAPassword =>
|
internal bool SelectedHostAsksForAPassword =>
|
||||||
SelectedHost is null
|
SelectedHost is null
|
||||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||||
|
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
||||||
@@ -4329,11 +4346,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// changed.
|
/// changed.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
|
internal string SelectedHostAuthenticationNote => SelectedHost switch
|
||||||
{
|
{
|
||||||
{ Kind: ResolvedBindingKind.Credential } binding =>
|
{ Host.SignsInThroughSpelunk: true } =>
|
||||||
|
"Spelunk signs you in to this host with a one-time key; no SSH key or password is needed.",
|
||||||
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential } binding } =>
|
||||||
$"This host uses a password stored in your keychain{From(binding)}.",
|
$"This host uses a password stored in your keychain{From(binding)}.",
|
||||||
{ Kind: ResolvedBindingKind.SshKey } binding =>
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey } binding } =>
|
||||||
$"This host authenticates with an SSH key{From(binding)}.",
|
$"This host authenticates with an SSH key{From(binding)}.",
|
||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
};
|
};
|
||||||
@@ -4362,14 +4381,19 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
|
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
|
||||||
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
|
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
|
||||||
/// value that had not loaded.
|
/// value that had not loaded. A host Spelunk signs in to logs in as whatever Spelunk names, so it says
|
||||||
|
/// that rather than a username the connection will not use.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
|
internal string SelectedHostUsernameLabel => SelectedHost switch
|
||||||
? user
|
{
|
||||||
: "no account set";
|
{ Host.SignsInThroughSpelunk: true } => "named by Spelunk",
|
||||||
|
{ Resolved.Username.Value: { Length: > 0 } user } => user,
|
||||||
|
_ => "no account set",
|
||||||
|
};
|
||||||
|
|
||||||
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
||||||
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
|
internal bool SelectedHostUsernameIsInherited =>
|
||||||
|
SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true };
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What the pane names in the credentials row: the key or password this host authenticates with.
|
/// What the pane names in the credentials row: the key or password this host authenticates with.
|
||||||
@@ -4385,11 +4409,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
|
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
|
internal string SelectedHostBindingLabel => SelectedHost switch
|
||||||
{
|
{
|
||||||
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
|
{ Host.SignsInThroughSpelunk: true } => SpelunkSignInIdentity,
|
||||||
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey, EntityId: { } id } } =>
|
||||||
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
|
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
|
||||||
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
|
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential, EntityId: { } id } } =>
|
||||||
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
||||||
?? "(a password that is no longer here)",
|
?? "(a password that is no longer here)",
|
||||||
_ => string.Empty,
|
_ => string.Empty,
|
||||||
@@ -4534,6 +4559,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
|
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
|
||||||
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
|
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
|
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
unreadable += await ReloadSpelunkKeysAsync(cancellationToken).ConfigureAwait(true);
|
||||||
unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
|
unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Last, because it reads the host list to work out which pins nothing dials any more.
|
// Last, because it reads the host list to work out which pins nothing dials any more.
|
||||||
@@ -4588,6 +4614,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// longer say what the user chose.
|
// longer say what the user chose.
|
||||||
var authentication = EditorSelectedAuthentication;
|
var authentication = EditorSelectedAuthentication;
|
||||||
var groupId = EditorSelectedGroup?.EntityId;
|
var groupId = EditorSelectedGroup?.EntityId;
|
||||||
|
var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId;
|
||||||
|
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
|
|
||||||
@@ -4606,6 +4633,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
|
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
|
||||||
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
|
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
|
||||||
grouped: EditorSelectedGroup?.EntityId is not null);
|
grouped: EditorSelectedGroup?.EntityId is not null);
|
||||||
|
|
||||||
|
BuildSpelunkKeyChoices(spelunkKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsEditingGroup)
|
if (IsEditingGroup)
|
||||||
@@ -4685,18 +4714,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
|
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Hosts.Clear();
|
|
||||||
|
|
||||||
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
|
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
|
||||||
// hold a host with the same label and both are shown: which vault it is in is what tells them
|
// hold a host with the same label and both are shown: which vault it is in is what tells them
|
||||||
// apart, which is why the row carries the name rather than the list deduplicating.
|
// apart, which is why the row carries the name rather than the list deduplicating. Swapped in whole,
|
||||||
foreach (var host in rows
|
// and announced once: see ReplaceableCollection for what a row at a time cost.
|
||||||
|
Hosts.ReplaceAll(rows
|
||||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
.ThenBy(row => row.Label, StringComparer.CurrentCulture));
|
||||||
{
|
|
||||||
Hosts.Add(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectedHost = SelectionAfterReload(selectedId);
|
SelectedHost = SelectionAfterReload(selectedId);
|
||||||
ApplyTheChosenHosts();
|
ApplyTheChosenHosts();
|
||||||
@@ -4787,13 +4812,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
var selectedId = SelectedObjectStore?.EntityId;
|
var selectedId = SelectedObjectStore?.EntityId;
|
||||||
|
|
||||||
ObjectStores.Clear();
|
ObjectStores.ReplaceAll(listing.Items
|
||||||
|
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture)
|
||||||
foreach (var store in listing.Items
|
.Select(store => new ObjectStoreRowViewModel(store)));
|
||||||
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
|
|
||||||
{
|
|
||||||
ObjectStores.Add(new ObjectStoreRowViewModel(store));
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
|
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
|
||||||
|
|
||||||
@@ -5359,12 +5380,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// a walk up the group tree, and this is the map that walk is made against.
|
// a walk up the group tree, and this is the map that walk is made against.
|
||||||
var parents = EffectiveParents();
|
var parents = EffectiveParents();
|
||||||
|
|
||||||
VisibleHosts.Clear();
|
VisibleHosts.ReplaceAll(Hosts.Where(host => Matches(host, parents)));
|
||||||
|
|
||||||
foreach (var host in Hosts.Where(host => Matches(host, parents)))
|
|
||||||
{
|
|
||||||
VisibleHosts.Add(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
RebuildSidebarRows();
|
RebuildSidebarRows();
|
||||||
|
|
||||||
@@ -5374,7 +5390,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// property directly, and a host that is not on screen is not one any of them should act on.
|
// property directly, and a host that is not on screen is not one any of them should act on.
|
||||||
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
|
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
|
||||||
|
|
||||||
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
|
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the reset
|
||||||
// above is a null that has to be overwritten rather than read.
|
// above is a null that has to be overwritten rather than read.
|
||||||
SelectedSidebarRow = SelectedHost;
|
SelectedSidebarRow = SelectedHost;
|
||||||
|
|
||||||
@@ -5444,12 +5460,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RebuildHostSections()
|
private void RebuildHostSections()
|
||||||
{
|
{
|
||||||
HostSections.Clear();
|
HostSections.ReplaceAll(BuildHostSections());
|
||||||
|
|
||||||
foreach (var built in BuildHostSections())
|
|
||||||
{
|
|
||||||
HostSections.Add(built);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed rather than stored, and none of the four has a change notification of its own — the same
|
// Computed rather than stored, and none of the four has a change notification of its own — the same
|
||||||
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
|
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
|
||||||
@@ -6338,7 +6349,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
|
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
|
||||||
// other order would show a newly shared vault as an empty one until the minute after.
|
// other order would show a newly shared vault as an empty one until the minute after.
|
||||||
await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
|
var vaultsChanged = await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Every vault this session can read, not only the one new items are filed into. A team's
|
// Every vault this session can read, not only the one new items are filed into. A team's
|
||||||
// vault that never synced would show its hosts exactly once — at the unlock that first
|
// vault that never synced would show its hosts exactly once — at the unlock that first
|
||||||
@@ -6351,6 +6362,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
||||||
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
||||||
|
|
||||||
|
if (!vaultsChanged && !MayHaveChangedTheKeychain(report))
|
||||||
|
{
|
||||||
|
// Nothing to redraw, so nothing is decrypted: see MayHaveChangedTheKeychain. The one thing a
|
||||||
|
// rebuild did that still has to happen is the ago-text on each card, which is a minute older.
|
||||||
|
RestringLastConnected();
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
|
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
|
||||||
@@ -6394,7 +6414,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
|
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
|
private async Task<bool> AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var before = session.Vaults.Count;
|
var before = session.Vaults.Count;
|
||||||
|
|
||||||
@@ -6402,12 +6422,39 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (admitted == 0 && session.Vaults.Count == before)
|
if (admitted == 0 && session.Vaults.Count == before)
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
VaultsChanged?.Invoke(this, EventArgs.Empty);
|
VaultsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a pass could have left the keychain different from the lists on screen.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every pass used to end by reading the whole keychain back — decrypting every host, key, password,
|
||||||
|
/// bucket and snippet and rebuilding every list from them — once a minute, whether or not a single item
|
||||||
|
/// had moved. Almost every pass moves nothing, so almost all of that was spent redrawing what was already
|
||||||
|
/// drawn.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// â—† Conservative on purpose. A failed vault counts as changed, because a pull can apply a page and then
|
||||||
|
/// fail on the next one, and a report with no counts is no evidence that nothing landed. Log entries do
|
||||||
|
/// not count — a machine reads back the ones it just wrote, and nothing on these lists is drawn from the
|
||||||
|
/// log; see <see cref="SyncReport.PulledItems"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool MayHaveChangedTheKeychain(IReadOnlyList<VaultSyncReport> report) =>
|
||||||
|
report.Any(vault => vault.Report is not { } pass
|
||||||
|
|| pass.PulledItems > 0
|
||||||
|
|| pass.PushedItems > 0
|
||||||
|
|| pass.Merged > 0
|
||||||
|
|| pass.Resurrected > 0
|
||||||
|
|| pass.DeletesAbandoned > 0
|
||||||
|
|| pass.Parked > 0
|
||||||
|
|| pass.ResyncedFromStart);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
|
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -7745,6 +7792,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorUsername = string.Empty;
|
EditorUsername = string.Empty;
|
||||||
EditorNotes = string.Empty;
|
EditorNotes = string.Empty;
|
||||||
EditorRelayEnabled = false;
|
EditorRelayEnabled = false;
|
||||||
|
LoadEditorSpelunk(route: null);
|
||||||
editorTagIds = TagSet.Empty;
|
editorTagIds = TagSet.Empty;
|
||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
@@ -7836,6 +7884,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorUsername = row.Host.Username ?? string.Empty;
|
EditorUsername = row.Host.Username ?? string.Empty;
|
||||||
EditorNotes = row.Host.Notes ?? string.Empty;
|
EditorNotes = row.Host.Notes ?? string.Empty;
|
||||||
EditorRelayEnabled = row.Host.RelayEnabled;
|
EditorRelayEnabled = row.Host.RelayEnabled;
|
||||||
|
LoadEditorSpelunk(row.Host.Spelunk);
|
||||||
editorTagIds = row.Host.TagIds;
|
editorTagIds = row.Host.TagIds;
|
||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
@@ -8815,53 +8864,94 @@ internal sealed partial class VaultViewModel(
|
|||||||
ArgumentNullException.ThrowIfNull(requests);
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
|
|
||||||
var target = vaultId ?? session.ActiveVaultId;
|
var target = vaultId ?? session.ActiveVaultId;
|
||||||
var hosts = 0;
|
var tally = new ImportTally();
|
||||||
var keys = 0;
|
var ran = false;
|
||||||
var refused = 0;
|
string? failure = null;
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
ran = true;
|
||||||
// front: the second host naming a file has to land on the id the first one produced, and
|
|
||||||
// nothing is reloaded until the batch finishes.
|
|
||||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var request in requests)
|
try
|
||||||
{
|
{
|
||||||
if (!request.Host.TryValidate(out _))
|
await ImportEachAsync(requests, target, tally, cancellationToken).ConfigureAwait(true);
|
||||||
{
|
}
|
||||||
refused++;
|
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||||
continue;
|
and not OperationCanceledException)
|
||||||
}
|
{
|
||||||
|
// Reported, and the hosts written before it are still reloaded below: they are in the
|
||||||
var (keyId, created) = await BindImportedKeyAsync(
|
// keychain whether or not the rest followed, and the screen has to say both.
|
||||||
request, bound, target, cancellationToken)
|
failure = exception.Message;
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
if (created)
|
|
||||||
{
|
|
||||||
keys++;
|
|
||||||
}
|
|
||||||
|
|
||||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
|
||||||
|
|
||||||
await session.Hosts
|
|
||||||
.CreateAsync(target, host, cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
hosts++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = DescribeImport(hosts, keys, refused);
|
Status = DescribeImport(tally.Hosts, tally.Keys, tally.Refused)
|
||||||
|
+ (failure is null ? string.Empty : $" The import stopped early: {failure}");
|
||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (!ran)
|
||||||
|
{
|
||||||
|
// RunAsync returns at once while anything else is running — a sync pass, a save — and the
|
||||||
|
// screen asking would otherwise have said it imported nothing as though that were the result.
|
||||||
|
failure = "the keychain was busy with something else. Nothing was imported; try again.";
|
||||||
|
}
|
||||||
|
|
||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
return new ImportOutcome(hosts, keys);
|
return new ImportOutcome(tally.Hosts, tally.Keys, tally.Refused, failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes each host of an import, and any key it brought, counting as it goes.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Counted into <paramref name="tally"/> rather than returned, so that when a write fails partway the
|
||||||
|
/// caller still knows how many landed before it — which is what it has to tell the person.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task ImportEachAsync(
|
||||||
|
IReadOnlyList<ImportedHostRequest> batch,
|
||||||
|
Guid into,
|
||||||
|
ImportTally tally,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||||
|
// front: the second host naming a file has to land on the id the first one produced, and nothing
|
||||||
|
// is reloaded until the batch finishes.
|
||||||
|
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var request in batch)
|
||||||
|
{
|
||||||
|
if (!request.Host.TryValidate(out _))
|
||||||
|
{
|
||||||
|
tally.Refused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (keyId, created) = await BindImportedKeyAsync(request, bound, into, token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (created)
|
||||||
|
{
|
||||||
|
tally.Keys++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||||
|
|
||||||
|
await session.Hosts.CreateAsync(into, host, token).ConfigureAwait(true);
|
||||||
|
|
||||||
|
tally.Hosts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What one import has written so far.</summary>
|
||||||
|
private sealed class ImportTally
|
||||||
|
{
|
||||||
|
internal int Hosts { get; set; }
|
||||||
|
|
||||||
|
internal int Keys { get; set; }
|
||||||
|
|
||||||
|
internal int Refused { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
||||||
@@ -11004,6 +11094,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case DeletionTarget.SpelunkKey:
|
||||||
|
await DeleteSpelunkKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -11047,9 +11141,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
|
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
|
||||||
{
|
{
|
||||||
var bound = Hosts
|
var bound = Hosts
|
||||||
.Where(row => row.Resolved.Binding is { } binding
|
.Where(row => Uses(row, kind, entityId))
|
||||||
&& binding.Kind == kind
|
|
||||||
&& binding.EntityId == entityId)
|
|
||||||
.Select(row => row.Label)
|
.Select(row => row.Label)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -11876,10 +11968,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
PhaseReporter reporter,
|
PhaseReporter reporter,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
|
// A relay or Spelunk refusal already says exactly what happened, and a routed host is reached by a
|
||||||
// probe does not take: it would dial the host directly — the route the user chose the relay because
|
// path the probe does not take: it would dial the host directly — the route the user chose the relay
|
||||||
// they cannot use — and report on that instead.
|
// or Spelunk because they cannot use, or for a Spelunk host an address that exists only to name its
|
||||||
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
|
// key — and report on that instead.
|
||||||
|
if (reporter.Furthest != SshConnectionPhase.Reaching
|
||||||
|
|| exception is ServerRelayException or SpelunkTunnelException
|
||||||
|
|| target.Routed)
|
||||||
{
|
{
|
||||||
return exception.Message;
|
return exception.Message;
|
||||||
}
|
}
|
||||||
@@ -11984,7 +12079,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication.Username,
|
authentication.Username,
|
||||||
authentication.Credential);
|
authentication.Credential);
|
||||||
|
|
||||||
var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
|
var (sessionId, dialled) = await OpenThroughAsync(target, request, reporter, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
// The account the session actually logged in as, which a Spelunk sign-in learns only from the tunnel.
|
||||||
|
authentication = authentication with { Username = dialled.Username };
|
||||||
|
|
||||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||||
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
||||||
@@ -12046,11 +12145,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// this machine without touching the host.
|
/// this machine without touching the host.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
|
/// Reached through <see cref="OpenRouteAsync"/>, which is what both transports call — file transfer
|
||||||
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
|
/// opens a connection of its own, and a relayed host needs the relay there too.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
private async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||||
{
|
{
|
||||||
@@ -12063,22 +12162,23 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
|
/// Opens the session, through the relay or Spelunk when the host uses one, and puts the route's reason on
|
||||||
|
/// a failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
/// A route that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
||||||
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
|
/// every lost transport — a sentence that reads exactly like the direct failure the route was chosen to
|
||||||
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
|
/// get around. When the route said why it closed, that sentence is the true one and replaces it. The
|
||||||
/// host key refusals are left alone: a key was presented, so the relay did its job.
|
/// host key refusals are left alone: a key was presented, so the route did its job.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
|
/// The route is disposed on every failure. The connection owns it once it is handed over and disposes
|
||||||
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
||||||
/// bridge listening until its own timeout; disposing twice is safe.
|
/// bridge listening until its own timeout; disposing twice is safe.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<uint> OpenThroughAsync(
|
private async Task<(uint SessionId, SshConnectionRequest Request)> OpenThroughAsync(
|
||||||
ConnectionTarget target,
|
ConnectionTarget target,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
PhaseReporter reporter,
|
PhaseReporter reporter,
|
||||||
@@ -12086,28 +12186,30 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
||||||
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
||||||
var relay = target.Relayed
|
var route = target.Row is { } row
|
||||||
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
|
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
var through = Through(request, route);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await workspace
|
var sessionId = await workspace
|
||||||
.OpenSessionAsync(
|
.OpenSessionAsync(through, TerminalSize.Default, reporter, cancellationToken)
|
||||||
relay is null ? request : request with { Proxy = relay.Proxy },
|
|
||||||
TerminalSize.Default,
|
|
||||||
reporter,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
|
||||||
catch (Exception exception) when (relay is not null)
|
|
||||||
{
|
|
||||||
await relay.DisposeAsync().ConfigureAwait(true);
|
|
||||||
|
|
||||||
if (relay.Failure is { } failure
|
return (sessionId, through);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (route is not null)
|
||||||
|
{
|
||||||
|
await route.DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (route.Failure is { } failure
|
||||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||||
{
|
{
|
||||||
throw new ServerRelayException(failure, exception);
|
throw route is SpelunkTunnelRoute
|
||||||
|
? new SpelunkTunnelException(failure, exception)
|
||||||
|
: new ServerRelayException(failure, exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
@@ -12334,13 +12436,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
internal Guid? HostId => Row?.EntityId;
|
internal Guid? HostId => Row?.EntityId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this is reached through the server relay rather than dialled.
|
/// Whether this is reached through the server relay or through Spelunk rather than dialled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
|
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
||||||
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
|
/// reached with a key the item names, and a machine typed into the manual box has neither.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool Relayed => Row?.Host.RelayEnabled is true;
|
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -12422,8 +12524,25 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
|
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
|
||||||
/// nothing about keys.
|
/// nothing about keys.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A host Spelunk signs in to is answered first, whatever it or its group binds</b> — see
|
||||||
|
/// <see cref="SignedInBySpelunk"/>. It is the one host that connects with no binding, because it says so;
|
||||||
|
/// every other host goes through <see cref="TryBuildBoundAuthentication"/> exactly as before.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private bool TryBuildAuthentication(
|
private bool TryBuildAuthentication(
|
||||||
|
HostSecret host,
|
||||||
|
ResolvedHost resolved,
|
||||||
|
string typedPassword,
|
||||||
|
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||||
|
[NotNullWhen(false)] out string? reason) =>
|
||||||
|
host.SignsInThroughSpelunk
|
||||||
|
? SignedInBySpelunk(out authentication, out reason)
|
||||||
|
: TryBuildBoundAuthentication(host, resolved, typedPassword, out authentication, out reason);
|
||||||
|
|
||||||
|
/// <summary>Turns a host's resolved binding into a key, a password or a prompt, or says why it cannot.</summary>
|
||||||
|
/// <remarks>See <see cref="TryBuildAuthentication"/>.</remarks>
|
||||||
|
private bool TryBuildBoundAuthentication(
|
||||||
HostSecret host,
|
HostSecret host,
|
||||||
ResolvedHost resolved,
|
ResolvedHost resolved,
|
||||||
string typedPassword,
|
string typedPassword,
|
||||||
@@ -12488,6 +12607,27 @@ internal sealed partial class VaultViewModel(
|
|||||||
out reason);
|
out reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How a host Spelunk signs in to authenticates, before the tunnel that decides it is open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Spelunk signs the host in with a key the tunnel makes and an account Spelunk names, neither of which
|
||||||
|
/// exists yet. These stand-ins are replaced as the connection is made through the route — see
|
||||||
|
/// <see cref="Through"/> — and refused by the SSH layer if they ever reach it. It is the only way to
|
||||||
|
/// connect without a binding, and only for a host that says so; every other host still needs one.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool SignedInBySpelunk(
|
||||||
|
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||||
|
out string? reason)
|
||||||
|
{
|
||||||
|
authentication = new HostAuthentication(
|
||||||
|
SpelunkSignIn.DefaultUser,
|
||||||
|
SpelunkSignInPending.Instance,
|
||||||
|
SpelunkSignInIdentity);
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The last thing every branch has to agree on: there is somebody to log in as.
|
/// The last thing every branch has to agree on: there is somebody to log in as.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -12549,6 +12689,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||||||
RelayEnabled = EditorRelayEnabled,
|
RelayEnabled = EditorRelayEnabled,
|
||||||
|
|
||||||
|
// Null unless the box is ticked, whatever the fields below it still hold: unticking is how a host
|
||||||
|
// goes back to being dialled, and it must not keep a route the user just turned off.
|
||||||
|
Spelunk = BuildSpelunkRoute(),
|
||||||
|
|
||||||
// Only ever true or null, never false: the two would mean the same thing, and writing false would
|
// Only ever true or null, never false: the two would mean the same thing, and writing false would
|
||||||
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
|
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
|
||||||
//
|
//
|
||||||
@@ -12556,7 +12700,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
|
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
|
||||||
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
|
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
|
||||||
// host in the vault the first time it was edited.
|
// host in the vault the first time it was edited.
|
||||||
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
// Never beside a Spelunk sign-in, nor are the bindings below: a host has one way to log in.
|
||||||
|
AsksForPassword = EditorAsksForLogin
|
||||||
|
&& EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
||||||
&& EditorSelectedGroup?.EntityId is not null
|
&& EditorSelectedGroup?.EntityId is not null
|
||||||
? true
|
? true
|
||||||
: null,
|
: null,
|
||||||
@@ -12591,7 +12737,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
||||||
private Guid? Bound(AuthenticationKind kind) =>
|
private Guid? Bound(AuthenticationKind kind) =>
|
||||||
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
EditorAsksForLogin && EditorSelectedAuthentication is { } choice && choice.Kind == kind
|
||||||
|
? choice.EntityId
|
||||||
|
: null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
||||||
@@ -13322,8 +13470,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
||||||
{
|
{
|
||||||
var names = Hosts
|
var names = Hosts
|
||||||
.Where(row => row.Resolved.Binding is { } binding
|
.Where(row => Uses(row, kind, entityId))
|
||||||
&& binding.Kind == kind && binding.EntityId == entityId)
|
|
||||||
.Select(row => row.Label)
|
.Select(row => row.Label)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -13336,6 +13483,16 @@ internal sealed partial class VaultViewModel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-reads what the keychain table says about its rows as a whole.</summary>
|
||||||
|
private void RaiseVaultItemFacts()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(SectionSummary));
|
||||||
|
OnPropertyChanged(nameof(HasVaultItems));
|
||||||
|
OnPropertyChanged(nameof(ShowsVaultColumn));
|
||||||
|
OnPropertyChanged(nameof(TotalItemCount));
|
||||||
|
OnPropertyChanged(nameof(EmptySectionMessage));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
||||||
@@ -13431,10 +13588,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||||||
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
|
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
|
||||||
|
|
||||||
OnPropertyChanged(nameof(SectionSummary));
|
RaiseVaultItemFacts();
|
||||||
OnPropertyChanged(nameof(HasVaultItems));
|
|
||||||
OnPropertyChanged(nameof(TotalItemCount));
|
|
||||||
OnPropertyChanged(nameof(EmptySectionMessage));
|
|
||||||
|
|
||||||
// A reload replaces every row object, and the selection is restored by id — so the setter above may
|
// A reload replaces every row object, and the selection is restored by id — so the setter above may
|
||||||
// not have fired even though the row this answers about is a different instance. Asked again here,
|
// not have fired even though the row this answers about is a different instance. Asked again here,
|
||||||
@@ -13518,8 +13672,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (kind is { } resolvedKind && value is not null)
|
if (kind is { } resolvedKind && value is not null)
|
||||||
{
|
{
|
||||||
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
|
foreach (var row in Hosts.Where(row => Uses(row, resolvedKind, value.EntityId)))
|
||||||
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
|
|
||||||
{
|
{
|
||||||
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1639,11 +1639,32 @@ internal sealed partial class VaultsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||||
// from a list box, which has no cancellation token and no way to await. Wrapped here rather than
|
// from a list box, which has no cancellation token and no way to await.
|
||||||
// inside the read, because the read is also awaited from inside a reload that already holds the
|
//
|
||||||
// busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
|
// ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
|
||||||
// members list entirely.
|
// a share, a reload — so a vault chosen in that moment never had its people read, and the panel
|
||||||
_ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
|
// went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
|
||||||
|
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
|
||||||
|
// and every answer for an earlier selection is discarded by it.
|
||||||
|
_ = LoadSelectionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
|
||||||
|
private async Task LoadSelectionAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (DodoSshApiException exception)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||||
|
and not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnPendingActionChanged(VaultActionRequest? value)
|
partial void OnPendingActionChanged(VaultActionRequest? value)
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ public interface IKnownHostStore
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
|
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Returns every key pinned for one endpoint, whatever the algorithm.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// â—† <b>What stops a pin being sidestepped by changing algorithm.</b> <see cref="FindAsync"/> answers
|
||||||
|
/// per algorithm, so on its own a host pinned for <c>ssh-ed25519</c> that is answered by somebody offering
|
||||||
|
/// only <c>ecdsa-sha2-nistp256</c> looks like a host never seen before — and "never seen before" is a
|
||||||
|
/// question the user is asked, where "changed" is a refusal. Knowing the endpoint has pins at all is what
|
||||||
|
/// lets the connection factory tell the two apart; see <c>SshNetConnectionFactory</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>The same rule as <see cref="FindAsync"/>: no I/O.</para>
|
||||||
|
/// </remarks>
|
||||||
|
ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Records a host key as trusted.</summary>
|
/// <summary>Records a host key as trusted.</summary>
|
||||||
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
|
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
|
||||||
|
|
||||||
@@ -156,6 +172,22 @@ public sealed class InMemoryKnownHostStore : IKnownHostStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
IReadOnlyList<HostKeyPresentation> found = pins.Values
|
||||||
|
.Where(pin => KnownHostIdentity.Comparer.Equals(pin.Host, host) && pin.Port == port)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
|
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -523,6 +523,14 @@ public interface IRemoteFileStore : IAsyncDisposable
|
|||||||
/// <param name="cancellationToken">Abandons the open.</param>
|
/// <param name="cancellationToken">Abandons the open.</param>
|
||||||
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Whether <see cref="OpenWriteAsync"/> can start anywhere but the beginning.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// True for SFTP, which is a file system. False for a bucket, where an object is written whole: an
|
||||||
|
/// interrupted upload there starts again rather than resuming, and the transfer queue reads this to know
|
||||||
|
/// — asking for an offset would be refused on every retry, leaving a row that can never finish.
|
||||||
|
/// </remarks>
|
||||||
|
bool CanWriteFromAnOffset => true;
|
||||||
|
|
||||||
/// <summary>Creates one directory, whose parent must exist.</summary>
|
/// <summary>Creates one directory, whose parent must exist.</summary>
|
||||||
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
IProgress<SshConnectionPhase>? progress,
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
|
var endpointPins = await knownHosts
|
||||||
|
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
|
||||||
|
|
||||||
|
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
|
||||||
|
|
||||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||||
|
|
||||||
@@ -217,17 +223,73 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
return gate;
|
return gate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The server takes the first algorithm in the client's list that it also supports, so this is what
|
||||||
|
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
|
||||||
|
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
|
||||||
|
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
|
||||||
|
/// may not be, which is exactly the choice it must not be left with.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
|
||||||
|
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
|
||||||
|
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
|
||||||
|
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void PreferPinnedAlgorithms(
|
||||||
|
ConnectionInfo info,
|
||||||
|
IReadOnlyList<HostKeyPresentation> pins)
|
||||||
|
{
|
||||||
|
if (pins.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinned = pins
|
||||||
|
.Select(pin => pin.Algorithm)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var ordered = info.HostKeyAlgorithms
|
||||||
|
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
info.HostKeyAlgorithms.Clear();
|
||||||
|
|
||||||
|
foreach (var algorithm in ordered)
|
||||||
|
{
|
||||||
|
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
|
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Separate from the connect method because the decision is the security-relevant part and reads
|
/// Separate from the connect method because the decision is the security-relevant part and reads
|
||||||
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
|
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
|
||||||
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
|
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// â—† <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
|
||||||
|
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
|
||||||
|
/// — so somebody in the middle who simply offered a different key type got the first-use question
|
||||||
|
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
|
||||||
|
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
|
||||||
|
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
|
||||||
|
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
|
||||||
|
/// refusing a server that still has its pinned key.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed class HostKeyGate(
|
private sealed class HostKeyGate(
|
||||||
IKnownHostStore knownHosts,
|
IKnownHostStore knownHosts,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
|
IReadOnlyList<HostKeyPresentation> endpointPins,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -264,13 +326,21 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
.GetAwaiter()
|
.GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
|
|
||||||
if (pinned is null)
|
if (pinned is null && endpointPins.Count == 0)
|
||||||
{
|
{
|
||||||
// Refused, not prompted. The caller decides, off the handshake thread.
|
// Refused, not prompted. The caller decides, off the handshake thread.
|
||||||
e.CanTrust = false;
|
e.CanTrust = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
|
||||||
|
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
|
||||||
|
// against the endpoint's first pin. See the class remarks.
|
||||||
|
pinned ??= endpointPins
|
||||||
|
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
|
||||||
|
?.Fingerprint
|
||||||
|
?? endpointPins[0].Fingerprint;
|
||||||
|
|
||||||
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
||||||
mismatch = !matches;
|
mismatch = !matches;
|
||||||
e.CanTrust = matches;
|
e.CanTrust = matches;
|
||||||
@@ -398,7 +468,21 @@ internal sealed class SshNetConnection(
|
|||||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// On the thread pool, because <c>CreateShellStream</c> is synchronous and is three round trips to the
|
||||||
|
/// server — open a channel, ask for a terminal, ask for a shell — and this used to make them on whichever
|
||||||
|
/// thread called it, which is the UI thread. On a distant or overloaded server the window stopped
|
||||||
|
/// painting for as long as they took, and the cancellation token it was given was read once, before any
|
||||||
|
/// of them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// SSH.NET's call cannot itself be cancelled, so a cancellation stops the wait rather than the call. A
|
||||||
|
/// shell that arrives after nobody is waiting for it is closed as it lands rather than left holding a
|
||||||
|
/// channel on the server.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -406,15 +490,53 @@ internal sealed class SshNetConnection(
|
|||||||
|
|
||||||
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
|
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
|
||||||
// only delays the first byte reaching the screen.
|
// only delays the first byte reaching the screen.
|
||||||
var shell = client.CreateShellStream(
|
var opening = Task.Run(
|
||||||
"xterm-256color",
|
() => client.CreateShellStream(
|
||||||
effective.Columns,
|
"xterm-256color",
|
||||||
effective.Rows,
|
effective.Columns,
|
||||||
effective.PixelWidth,
|
effective.Rows,
|
||||||
effective.PixelHeight,
|
effective.PixelWidth,
|
||||||
4096);
|
effective.PixelHeight,
|
||||||
|
4096),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
|
try
|
||||||
|
{
|
||||||
|
var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new SshNetShellSession(shell, client);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_ = CloseWhenItLandsAsync(opening);
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes a shell nobody is waiting for any more, or lets its failure go unremarked.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A shell that failed to open after its caller gave up has nobody to tell, and the connection it was
|
||||||
|
/// on reports its own death through the session that owns it. The failures are the ones SSH.NET raises
|
||||||
|
/// for a channel or a connection that went away, named so that anything else still surfaces.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task CloseWhenItLandsAsync(Task<ShellStream> opening)
|
||||||
|
{
|
||||||
|
ShellStream shell;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
shell = await opening.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SshException
|
||||||
|
or System.Net.Sockets.SocketException
|
||||||
|
or ObjectDisposedException
|
||||||
|
or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await shell.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user