Public Access
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e6bdb75db | ||
|
|
df64012865 | ||
|
|
8543c69cca | ||
|
|
88bd924d83 | ||
|
|
abd7e56bb5 | ||
|
|
57d3d3c801 | ||
|
|
e0509c6679 | ||
|
|
6f6e92f315 | ||
|
|
ef365f462e | ||
|
|
f7354aa7e8 | ||
|
|
d71f057bde | ||
|
|
36b6ded63e | ||
|
|
fa85ad1eff | ||
|
|
0cecf1afc4 | ||
|
|
089de72834 | ||
|
|
3c8c0980c2 |
No files matched your search
@@ -456,30 +456,40 @@ phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot
|
|||||||
|
|
||||||
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
|
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
|
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
|
Spelunk's API address and the server's id in Spelunk. A connection then asks Spelunk for a ticket, opens a
|
||||||
for a ticket, opens a WebSocket, and the agent connects to the SSH server on the machine's own loopback — so
|
WebSocket, and the agent connects to the SSH server on the machine's own loopback — so the server needs no
|
||||||
the server needs no open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk
|
open SSH port at all. Shells and file transfers both go this way; the relay and Spelunk are one choice, and
|
||||||
are one choice, and ticking one unticks the other.
|
ticking one unticks the other.
|
||||||
|
|
||||||
- **Fill in the API key once, under Settings → Security → Spelunk.** Give Spelunk's API address and paste
|
- **Sign this device in to Spelunk once, under Settings → Security → Spelunk** (or with the button in a
|
||||||
the key (`spelunk_…`, with the Admin role). It is saved as a *Spelunk key* in your personal vault —
|
Spelunk host's editor). Give Spelunk's API address and press SIGN IN: your browser opens at Spelunk, you
|
||||||
encrypted, synced to your other devices, never shown again — together with the address, and **it is only
|
sign in there however you normally do — password, passkey, your company's single sign-on, second factor —
|
||||||
ever sent to that address**: a host that names the key with any other address is refused at connect. One
|
and allow *DodoSSH on "<this device>"*. No key is pasted anywhere, and DodoSSH never sees your Spelunk
|
||||||
key serves every server in that Spelunk organisation; saving a key for an address that already has one
|
password.
|
||||||
replaces it, and every host using it connects with the new key. The host editor offers saved keys first
|
- **One sign-in reaches every organisation you are in** on that Spelunk, as you, with the role you have on
|
||||||
and picks the one for the address you type. The API address must be `https://`, except for a Spelunk on
|
each server at the time: Admin opens a terminal, anything less is refused for that server alone. DodoSSH
|
||||||
this machine.
|
sends no organisation; Spelunk takes it from the server.
|
||||||
- A key kept the older way, as a credential's password, still works and is still offered in the editor;
|
- **It stays on this device.** Nothing about it is synced: each device signs in on its own, and the host
|
||||||
its password goes to whatever address the host names.
|
itself carries only the address and the server id, so the same host opens on every device signed in to its
|
||||||
|
Spelunk. The sign-in is sealed in the local cache like the remembered DodoSSH sign-in, and only ever sent to
|
||||||
|
the Spelunk it came from. The API address must be `https://`, except for a Spelunk on this machine.
|
||||||
|
- **It has no expiry, and ends with you.** Spelunk refuses it once you are removed, change your password or
|
||||||
|
second factors, or sign it out under *Your account → Signed-in apps* there; DodoSSH then forgets it and asks
|
||||||
|
you to sign in again. SIGN OUT beside it under Settings → Security ends it in both places, and so does
|
||||||
|
signing out of DodoSSH.
|
||||||
|
- **DodoSSH has to be online and signed in.** Before every connection through Spelunk it asks its identity
|
||||||
|
provider whether your DodoSSH sign-in still stands (at most once a minute). An account the provider no longer
|
||||||
|
signs in is signed out of DodoSSH and of every Spelunk on this device; a provider that cannot be reached
|
||||||
|
holds the connection until it can.
|
||||||
- **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it
|
- **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.
|
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
|
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: signed out, not
|
||||||
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
|
allowed on that server, a server Spelunk does not know, an agent that is not connected, an agent that could
|
||||||
connected, an agent that could not reach the SSH port or is too old to open terminals.
|
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
|
**Sign in through Spelunk (no SSH key needed)**, the last box 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
|
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;
|
Ed25519 key pair in memory and sends the public half with the ticket request, which your sign-in authorises;
|
||||||
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
|
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
|
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,
|
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
|
||||||
@@ -493,15 +503,14 @@ link, or hands the link to the copy already running, and **connects straight awa
|
|||||||
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
|
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.
|
- 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
|
- Otherwise, when this device is signed in to that Spelunk, DodoSSH saves a new host in your personal vault —
|
||||||
already sends an API key there — DodoSSH saves a new host in your personal vault — named as the link
|
named as the link names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through
|
||||||
names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through Spelunk with that
|
Spelunk, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any
|
||||||
key, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any host
|
host you saved yourself.
|
||||||
you saved yourself.
|
- **A link from a Spelunk this device is not signed in to** opens the host editor instead, filled in the same
|
||||||
- **A link from a Spelunk with no key** opens the host editor instead, filled in the same way with sign-in
|
way with sign-in ticked and the tag on, and offers to sign in to that Spelunk. Sign in, save, and every later
|
||||||
ticked and the tag on, and you choose that Spelunk's API key once and save. Every later link from that
|
link from that Spelunk connects straight away. A link can be sent by anything on this machine, so it never
|
||||||
Spelunk connects straight away. A link can be sent by anything on this machine, so an API key is never
|
starts a sign-in by itself.
|
||||||
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
|
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.
|
its own. A link that arrives while the vault is locked waits for the unlock.
|
||||||
|
|||||||
+7
-3
@@ -300,7 +300,7 @@ 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.
|
`18` SpelunkSignIn. `17` is retired and never reused.
|
||||||
|
|
||||||
> **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
|
||||||
@@ -319,8 +319,12 @@ 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
|
> **Added 2026-09-29, removed 2026-10-07:** `17` SpelunkKey, a Spelunk API key and the address it
|
||||||
> `14` on the wire, three behind, as `16` ObjectStore is `13`.
|
> may be sent to (`14` on the wire). Removed in beta when DodoSSH started signing in to Spelunk instead;
|
||||||
|
> both numbers stay retired.
|
||||||
|
|
||||||
|
> **Added 2026-10-07:** `18` SpelunkSignIn, a device's sign-in to one Spelunk, sealed in the local cache
|
||||||
|
> (§4.2's LocalCache AAD) and bound to its row. It is never synced and has no `SyncEntityType`.
|
||||||
|
|
||||||
`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.
|
||||||
|
|
||||||
|
|||||||
+56
-7
@@ -1404,6 +1404,23 @@ MACHINE box, which takes the same path — `VaultViewModel.ConnectToEndpointAsyn
|
|||||||
the two is in how the palette hands the password over, not in the connection. The headless half of this is
|
the two is in how the palette hands the password over, not in the connection. The headless half of this is
|
||||||
`QuickConnectTests`; what it cannot do is dial.
|
`QuickConnectTests`; what it cannot do is dial.
|
||||||
|
|
||||||
|
### 7.10b Pressing Terminal with nothing open asks what to connect to
|
||||||
|
|
||||||
|
Close every terminal tab, then press **Terminal** on the rail.
|
||||||
|
|
||||||
|
**Pass:** the terminal's frame holds a prompt — `connect to:` with the caret already on it, so typing works
|
||||||
|
without a click. Type part of a saved host's name: the matching hosts appear under the line, Tab completes to
|
||||||
|
the first, and Enter connects to it — asking `user@host:port's password:` first only if that host has no key
|
||||||
|
or saved password. Type `user@host` for a machine that is not saved instead: Enter asks for its password, and
|
||||||
|
the second Enter opens a tab for it. Escape steps back from the password to the name. Afterwards HOSTS has
|
||||||
|
nothing new on it, and no credential was saved — even with "remember" ticked in a host's drawer earlier.
|
||||||
|
|
||||||
|
**Failure means:** a prompt that needs a click before it takes typing is the terminal's WebView still holding
|
||||||
|
the native keyboard focus — see `NativeKeyboardFocus`, which `ConnectPrompt.TakeKeyboard` calls. Anything drawn
|
||||||
|
*under* a blank rectangle is the occlusion rule broken: the prompt may only show while no tab exists. A new
|
||||||
|
credential in KEYS after connecting is the prompt's attempt acting on the drawer's tick, which
|
||||||
|
`ShellFlowTests.ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn` exists to stop.
|
||||||
|
|
||||||
### 7.11 A host or a bucket made now can be picked on FILES now
|
### 7.11 A host or a bucket made now can be picked on FILES now
|
||||||
|
|
||||||
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
With the files screen's picker open on an empty or short list, go to HOSTS and add a host — or to KEYS and
|
||||||
@@ -2082,14 +2099,16 @@ that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
|
|||||||
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
||||||
nothing running.
|
nothing running.
|
||||||
|
|
||||||
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready
|
**Pass:** tapping the keychain one puts **its name** on the `connect to:` line and carries on as typing that
|
||||||
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and
|
name would — its password line, or straight into a terminal for a host with a key or a saved password.
|
||||||
leaves the password box **empty**. Neither one dials anything on its own.
|
Tapping the hand-typed one puts its `user@host:port` on the line and asks for its password with the password
|
||||||
|
line **empty**. Neither one leaves this screen, and the hand-typed one dials nothing until a password is
|
||||||
|
given.
|
||||||
|
|
||||||
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
|
**Failure means:** if the hand-typed one shows a host's name rather than its address, the log entry is
|
||||||
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
|
carrying a host id it should not have. If the password came back, something is storing it — it never was.
|
||||||
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
|
And if the keychain one shows the logged address rather than the host's name, the host id is not being
|
||||||
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
|
matched against the keychain — see `MainWindowViewModel.ChoosePromptRecentAsync`.
|
||||||
|
|
||||||
### 11.9 The keychain and snippets are the vault's, and back walks out of them
|
### 11.9 The keychain and snippets are the vault's, and back walks out of them
|
||||||
|
|
||||||
@@ -2318,6 +2337,23 @@ Android is not delivering the click as a `contextmenu` event, and the gesture ne
|
|||||||
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
||||||
has no right button to miss.
|
has no right button to miss.
|
||||||
|
|
||||||
|
### 11.14 Connections with nothing open asks what to connect to
|
||||||
|
|
||||||
|
Close every shell, then press **Connections**. Type part of a saved host's name into the `connect to:` line.
|
||||||
|
|
||||||
|
**Pass:** the matching hosts appear under the line as buttons a thumb can hit; tapping one asks for that
|
||||||
|
host's password on a line of its own under ssh's wording — or connects at once, for a host with a key or a
|
||||||
|
saved password. Then type `user@host` for a machine that is not saved and press CONNECT: the password line
|
||||||
|
appears with the keyboard already on it, and BACK — or the system back gesture — steps off it onto the
|
||||||
|
address rather than leaving the screen. A tap on a RECENT row lands on the prompt and asks whatever it needs
|
||||||
|
to before anything is dialled. Nothing new appears on HOSTS or in the keychain afterwards.
|
||||||
|
|
||||||
|
**Failure means:** a password line that appears with the keyboard still on the first line is the posted focus
|
||||||
|
in `ConnectPrompt.axaml.cs` arriving before the box has bounds. Back that leaves Connections from the password
|
||||||
|
line is `PhoneShell.OnBackRequested`'s prompt step having been reordered below the surface check. The
|
||||||
|
decisions themselves — which name matches, what is refused, what is saved — are `ShellFlowTests`' prompt
|
||||||
|
cases, shared with the desktop; this is the rectangles and the keyboard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||||
@@ -3502,3 +3538,16 @@ Register a device, sync something, then `sudo dnf remove dodossh`.
|
|||||||
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
||||||
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
||||||
something wrote into the install directory at runtime and would survive a reinstall.
|
something wrote into the install directory at runtime and would survive a reinstall.
|
||||||
|
|
||||||
|
### 19.13 A copy at the same path that rpm does not own is offered nothing
|
||||||
|
|
||||||
|
On a distribution without rpm — Arch, say, with a locally built package that puts the nightly tree at
|
||||||
|
`/opt/DodoSSH.Nightly` — open PREFERENCES → UPDATES.
|
||||||
|
|
||||||
|
**Pass:** **CHECK NOW** is disabled and the row says this copy was installed outside the `dodossh-nightly`
|
||||||
|
RPM and should be updated with the package manager that installed it. `~/.local/share/dodossh.nightly/updates/`
|
||||||
|
stays empty however long it runs.
|
||||||
|
|
||||||
|
**Failure means:** a banner offering a nightly is the path being taken as proof the RPM is installed, or an
|
||||||
|
rpm that cannot answer being read as an old version — the fault this check was written for, which offered
|
||||||
|
every nightly as newer and fetched a 56 MB package the machine could never install, every six hours.
|
||||||
@@ -73,7 +73,6 @@ 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>
|
||||||
@@ -1094,98 +1093,3 @@ 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;
|
|
||||||
}
|
|
||||||
@@ -56,7 +56,6 @@ internal sealed class TombstoneCollector(
|
|||||||
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
|
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||||
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
|
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||||
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
|
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
|
||||||
collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false);
|
|
||||||
|
|
||||||
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
|
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -246,11 +246,7 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
knownHosts,
|
knownHosts,
|
||||||
deviceKeys,
|
deviceKeys,
|
||||||
|
|
||||||
// Difference 4: the system browser by intent, and the response by intent too rather than on a
|
SignInThroughBrowser(browser),
|
||||||
// loopback socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
|
||||||
async (url, cancellationToken) => await ServerConnection
|
|
||||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
|
||||||
.ConfigureAwait(false),
|
|
||||||
|
|
||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
connections,
|
connections,
|
||||||
@@ -271,7 +267,9 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
copyToClipboard: copyToClipboard,
|
copyToClipboard: copyToClipboard,
|
||||||
readClipboard: readClipboard,
|
readClipboard: readClipboard,
|
||||||
|
|
||||||
updates: updates);
|
updates: updates,
|
||||||
|
|
||||||
|
spelunkAccounts: PhoneSpelunkAccounts(browser));
|
||||||
|
|
||||||
// Started rather than awaited: framework initialisation must not block on a schema migration. The
|
// Started rather than awaited: framework initialisation must not block on a schema migration. The
|
||||||
// view model shows its own progress and handles its own failures.
|
// view model shows its own progress and handles its own failures.
|
||||||
@@ -280,6 +278,23 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
return viewModel;
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Difference 4: the system browser by intent, and the response by intent too rather than on a loopback
|
||||||
|
/// socket. See AndroidAuthorization for why the desktop's listener is not reused.
|
||||||
|
/// </summary>
|
||||||
|
private static MainWindowViewModel.SignInHandler SignInThroughBrowser(AndroidBrowserLauncher browser) =>
|
||||||
|
async (url, cancellationToken) => await ServerConnection
|
||||||
|
.SignInAsync(url, browser, TimeProvider.System, cancellationToken, ConfigureAndroidOidc)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signing in to Spelunk comes back the way signing in to DodoSSH does — by intent, to the same registered
|
||||||
|
/// scheme, at the dev.dodotech.dodossh:/callback Spelunk allows — and names this phone as the account's
|
||||||
|
/// device list does, for difference 5's reason.
|
||||||
|
/// </summary>
|
||||||
|
private static SpelunkAccounts PhoneSpelunkAccounts(AndroidBrowserLauncher browser) =>
|
||||||
|
new(browser, path => new AndroidRedirectCallback(path), TimeProvider.System, PhoneEnvironment.DeviceName);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Difference 4: where the authorization response comes back to.
|
/// Difference 4: where the authorization response comes back to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -843,6 +843,30 @@
|
|||||||
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The Connections prompt's two lines, typed into the way a shell is: no fill and no border in any state, so
|
||||||
|
the text sits on the pane where its label leaves off and the caret is the only sign the line takes input.
|
||||||
|
44 tall all the same, which is this head's touch floor — the line has no box to aim at, so the whole
|
||||||
|
height of it has to be the target. See ConnectPrompt.axaml. Declared after .secret, which it combines with
|
||||||
|
on the password line and sets nothing in common with.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBox.prompt">
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
||||||
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||||
|
x:Class="DodoSSH.Client.Android.Views.ConnectPrompt"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The phone's half of the desktop's empty-terminal prompt, drawn on Connections while nothing is open: the
|
||||||
|
same two lines, over the same view-model state — MainWindowViewModel.PromptText and its siblings — so a
|
||||||
|
name or an address typed here is read, refused and dialled exactly as it is on the desktop. See the
|
||||||
|
desktop's ConnectPrompt.axaml for the reasoning; what is here is only what a phone changes.
|
||||||
|
|
||||||
|
THREE THINGS A PHONE CHANGES.
|
||||||
|
|
||||||
|
No Tab and no Escape. The matches under the first line are buttons sized for a thumb instead of a
|
||||||
|
completion list for a key, and a tap on one takes it outright — completing to a name and then pressing
|
||||||
|
CONNECT is two taps for what one already says. The way off the password line is BACK beside CONNECT,
|
||||||
|
and the system back gesture, which PhoneShell routes to the same command.
|
||||||
|
|
||||||
|
A CONNECT button, because the software keyboard's own return key may commit or may only close the
|
||||||
|
keyboard — that is the IME's choice, as the files screen's GO button already says. Enter is still bound,
|
||||||
|
for every keyboard that does send it.
|
||||||
|
|
||||||
|
The password's label on its own line. ssh's wording — "deploy@build.internal:2222's password:" — is a
|
||||||
|
third of a phone's width before anything is typed, so the box goes under it rather than beside it.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="12" Padding="14,12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||||
|
Text="Type a saved host's name, or user@host[:port] for a machine that is not saved." />
|
||||||
|
|
||||||
|
<!-- ============ what to connect to ============ -->
|
||||||
|
<Grid ColumnDefinitions="Auto,*" Margin="0,4,0,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="14" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||||
|
AutomationProperties.Name="Connect to"
|
||||||
|
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- The saved hosts that match so far, best first. A tap takes one: it connects, or asks its password. -->
|
||||||
|
<ItemsControl ItemsSource="{Binding PromptMatches}" IsVisible="{Binding ShowsPromptMatches}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||||
|
<Button Classes="chiptoggle" MinHeight="44" Margin="0,0,6,6"
|
||||||
|
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Text="{Binding Label}" />
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- ============ the password, when one is needed ============ -->
|
||||||
|
<StackPanel x:Name="PasswordLine" Spacing="0" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource Text}" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PromptPasswordLabel}" />
|
||||||
|
<!--
|
||||||
|
Classes="secret" for what it tells the software keyboard, not only for the dots: see Phone.axaml.
|
||||||
|
-->
|
||||||
|
<TextBox x:Name="Password" Classes="prompt secret" AutomationProperties.Name="Password"
|
||||||
|
Text="{Binding PromptPassword}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||||
|
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
CONNECT always; BACK only on the password line, where there is a line to go back from. Side by side,
|
||||||
|
CONNECT on the right where the thumb ends up, the order the hosts screen's own panels use.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="BACK"
|
||||||
|
Command="{Binding CancelPromptCommand}" IsVisible="{Binding IsPromptAskingForPassword}" />
|
||||||
|
<Button Grid.Column="2" Classes="primary" Height="44" Content="CONNECT"
|
||||||
|
Command="{Binding SubmitPromptCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>The phone's empty-terminal prompt: what to connect to, and its password when one is needed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The one thing the code-behind does is move the keyboard onto the password line when it appears. Enter on a
|
||||||
|
/// name is answered with a second question, and a keyboard left on the first line — read-only by then — would
|
||||||
|
/// type nothing and look as though it should.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It does not take the keyboard when the screen opens, unlike the desktop's prompt. On a phone that would
|
||||||
|
/// raise the software keyboard over the recent connections underneath, which are the likelier tap.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>FindControl</c> rather than the generated <c>x:Name</c> fields, for the reason
|
||||||
|
/// <c>HostsScreen</c>'s constructor gives: no view on this head calls <c>InitializeComponent</c>, so those
|
||||||
|
/// fields are null at run time.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class ConnectPrompt : UserControl
|
||||||
|
{
|
||||||
|
public ConnectPrompt()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
var line = this.FindControl<StackPanel>("PasswordLine")!;
|
||||||
|
var box = this.FindControl<TextBox>("Password")!;
|
||||||
|
|
||||||
|
line.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Property == IsVisibleProperty && line.IsVisible)
|
||||||
|
{
|
||||||
|
// Posted at Loaded, as HostsScreen's own password sheet is: the box has no bounds on the turn
|
||||||
|
// its line becomes visible, and focusing a control with none raises no keyboard.
|
||||||
|
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1326,8 +1326,8 @@
|
|||||||
|
|
||||||
<!--
|
<!--
|
||||||
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
|
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
|
one unticks the other. No credential is chosen here: the host is reached as whoever this phone signed
|
||||||
VaultViewModel.Spelunk.cs.
|
in to that Spelunk as, and a button signs in when it has not. See VaultViewModel.Spelunk.cs.
|
||||||
-->
|
-->
|
||||||
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
||||||
<TextBlock Classes="mono" FontSize="11.5"
|
<TextBlock Classes="mono" FontSize="11.5"
|
||||||
@@ -1339,16 +1339,13 @@
|
|||||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||||
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
|
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
|
||||||
PlaceholderText="Spelunk server id" />
|
PlaceholderText="Spelunk server id" />
|
||||||
<TextBlock Classes="detail" Text="Spelunk API key" />
|
<TextBlock Classes="detail" TextWrapping="Wrap"
|
||||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
Text="{Binding EditorSpelunkSignInNote}"
|
||||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
HorizontalAlignment="Stretch" MinHeight="44">
|
<Button Classes="secondary" MinHeight="44" HorizontalAlignment="Stretch"
|
||||||
<ComboBox.ItemTemplate>
|
Content="SIGN IN TO SPELUNK"
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
Command="{Binding SignInToEditorSpelunkCommand}"
|
||||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
IsVisible="{Binding EditorSpelunkNeedsSignIn}" />
|
||||||
</DataTemplate>
|
|
||||||
</ComboBox.ItemTemplate>
|
|
||||||
</ComboBox>
|
|
||||||
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
||||||
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
||||||
<TextBlock Classes="mono" FontSize="11.5"
|
<TextBlock Classes="mono" FontSize="11.5"
|
||||||
@@ -1357,7 +1354,7 @@
|
|||||||
</CheckBox>
|
</CheckBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Classes="body"
|
<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." />
|
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. 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
|
||||||
|
|||||||
@@ -674,6 +674,16 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Connections prompt's password line, which back steps off onto the name it asked about — the
|
||||||
|
// same thing its BACK button does, and the nearer of the two things back could mean here. Leaving the
|
||||||
|
// surface would throw the name away along with the question.
|
||||||
|
if (current is { IsConnectPromptShowing: true, IsPromptAskingForPassword: true })
|
||||||
|
{
|
||||||
|
current.CancelPromptCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!current.IsShowingPages)
|
if (!current.IsShowingPages)
|
||||||
{
|
{
|
||||||
current.ShowScreenCommand.Execute(current.Screen);
|
current.ShowScreenCommand.Execute(current.Screen);
|
||||||
|
|||||||
@@ -345,9 +345,11 @@
|
|||||||
running this surface is where a connection is made rather than a page apologising for having
|
running this surface is where a connection is made rather than a page apologising for having
|
||||||
nothing on it. Two things are offered, in the order somebody reaches for them.
|
nothing on it. Two things are offered, in the order somebody reaches for them.
|
||||||
|
|
||||||
A box, because the case this application had no answer for is the address somebody has just been
|
A prompt, because a terminal with nothing in it should do what a terminal does, which is ask — the
|
||||||
given. Everything else here starts from a keychain item, which is right for a machine used twice and
|
same one the desktop draws in its empty terminal, over the same view-model state. It takes a saved
|
||||||
is several screens of work for one used once.
|
host's name or user@host for a machine that is not saved, and asks for a password only when one is
|
||||||
|
needed. See ConnectPrompt.axaml. It replaced a box that took addresses alone, which left a saved
|
||||||
|
machine several screens away from here.
|
||||||
|
|
||||||
And the machines already connected to, under it, because that is the commoner half by far: the
|
And the machines already connected to, under it, because that is the commoner half by far: the
|
||||||
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
|
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
|
||||||
@@ -359,44 +361,7 @@
|
|||||||
|
|
||||||
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
|
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
|
||||||
|
|
||||||
<!--
|
<views:ConnectPrompt />
|
||||||
The manual target. One box in the grammar people already have — user@host, or user@host:port —
|
|
||||||
rather than three, because three boxes on a phone is three taps between three keyboards for
|
|
||||||
something that arrives as one string.
|
|
||||||
-->
|
|
||||||
<TextBox Classes="field" Text="{Binding Vault.ManualTarget}"
|
|
||||||
PlaceholderText="user@host or user@host:port">
|
|
||||||
<TextBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
|
||||||
</TextBox.KeyBindings>
|
|
||||||
</TextBox>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
A typed password and nothing else. Offering the keychain's keys here would be a second binding
|
|
||||||
resolution beside the one the connect path already has, and a key is the reason to save the
|
|
||||||
machine as a host rather than dial it from a box.
|
|
||||||
|
|
||||||
Classes="secret" and not merely PasswordChar: the class is what also tells the software keyboard
|
|
||||||
this is not prose, which is the difference between dots on screen and dots on screen with the
|
|
||||||
password in the IME's dictionary. See Theme/Phone.axaml.
|
|
||||||
-->
|
|
||||||
<TextBox Classes="field secret" Text="{Binding Vault.ManualPassword}"
|
|
||||||
PlaceholderText="password">
|
|
||||||
<TextBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
|
||||||
</TextBox.KeyBindings>
|
|
||||||
</TextBox>
|
|
||||||
|
|
||||||
<Button Classes="primary" Content="CONNECT" Command="{Binding Vault.ConnectManuallyCommand}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Beside the box rather than on the shell's status line. What fails here is about what was typed —
|
|
||||||
a missing account, a port that is not a number — and a sentence about a text box belongs under
|
|
||||||
the text box.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
|
||||||
Text="{Binding Vault.ManualStatus}"
|
|
||||||
IsVisible="{Binding Vault.ManualStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
|
||||||
|
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
|
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
|
||||||
@@ -410,14 +375,13 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||||
<!--
|
<!--
|
||||||
Tapping one does not connect, and that is deliberate rather than a missing step. A row
|
A tap goes onto the prompt above and carries on as if it had been typed: a keychain host
|
||||||
that names a keychain host goes to that host with its connect bar — which is where its
|
connects, or asks for its password there; an address asks for its password, which was
|
||||||
key, or its password box, or its refusal lives — and one that names only an address goes
|
never stored. Nothing is dialled before the prompt has asked whatever it needs to. See
|
||||||
back into the box above, without the password, which was never stored. Either way what
|
MainWindowViewModel.ChoosePromptRecentCommand.
|
||||||
happens next is visible before anything is dialled. See ConnectToRecentCommand.
|
|
||||||
-->
|
-->
|
||||||
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
|
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
|
||||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ConnectToRecentCommand}"
|
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||||
|
|||||||
@@ -873,6 +873,29 @@
|
|||||||
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
<Setter Property="BorderBrush" Value="{StaticResource AccentSoft}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE EMPTY TERMINAL'S PROMPT LINES, which are typed into the way a shell is: no fill, no border and no
|
||||||
|
padding in any state, so the text sits on the pane exactly where the label before it leaves off, and the
|
||||||
|
caret is the only sign the line takes input. A box drawn around it would turn a prompt back into a form.
|
||||||
|
See ConnectPrompt.axaml. After the base rules and .pathbox, for the reason this file keeps restating.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBox.prompt">
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="13" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||||
|
<Setter Property="MinHeight" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
Lists. The rows in this design are drawn by their own templates and marked as selected with an accent
|
||||||
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
strip down the left edge, so the theme's rounded blue selection block has to go — it would sit behind
|
||||||
@@ -1200,10 +1223,14 @@
|
|||||||
The two buttons in the drawer's header: the overflow menu and the arrow that puts the drawer away.
|
The two buttons in the drawer's header: the overflow menu and the arrow that puts the drawer away.
|
||||||
Chrome rather than controls — they are the frame around whichever panel is showing, which is why they
|
Chrome rather than controls — they are the frame around whichever panel is showing, which is why they
|
||||||
are flat and quiet until the pointer finds them.
|
are flat and quiet until the pointer finds them.
|
||||||
|
|
||||||
|
No padding, because the box is fixed: Fluent's own 8,5,8,6 left a glyph ten pixels of width inside
|
||||||
|
twenty-six, and every 15px Material icon on the SFTP panes was drawn with its sides cut off.
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.paneicon">
|
<Style Selector="Button.paneicon">
|
||||||
<Setter Property="Width" Value="26" />
|
<Setter Property="Width" Value="26" />
|
||||||
<Setter Property="Height" Value="26" />
|
<Setter Property="Height" Value="26" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="6" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="FontSize" Value="13" />
|
<Setter Property="FontSize" Value="13" />
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
|||||||
@@ -127,17 +127,61 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
this.installedVersion = installedVersion;
|
this.installedVersion = installedVersion;
|
||||||
this.handToInstaller = handToInstaller;
|
this.handToInstaller = handToInstaller;
|
||||||
|
|
||||||
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
|
packageName = PackageFor(channel);
|
||||||
? "dodossh-nightly"
|
}
|
||||||
: "dodossh";
|
|
||||||
|
/// <summary>The channel for a copy under the RPM's install root, if rpm says the package is there.</summary>
|
||||||
|
internal static IUpdateChannel ForInstall(string channel) =>
|
||||||
|
ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests
|
||||||
|
/// reach it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The path says where this copy is and only rpm says who put it there.</b>
|
||||||
|
/// <c>UpdateChannels.LinuxChannel</c> has already established the first, and on an RPM distribution
|
||||||
|
/// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors
|
||||||
|
/// the spec puts the same tree at the same path, and this channel then offered every nightly as newer
|
||||||
|
/// than a version nobody could read, fetched a package that machine has no way to install, and did it
|
||||||
|
/// again every six hours.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asked once, at composition, for the reason the rest of this decision is: who installed this copy
|
||||||
|
/// does not change while it runs. It costs one <c>rpm</c> launch at start on a machine whose path
|
||||||
|
/// already matched, and nothing at all anywhere else.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="channel">The build's channel, which names the package.</param>
|
||||||
|
/// <param name="installedVersion">What rpm says is installed under a package name, or null.</param>
|
||||||
|
/// <param name="whenOwned">The channel to use when rpm does own this copy.</param>
|
||||||
|
internal static IUpdateChannel ForInstall(
|
||||||
|
string channel,
|
||||||
|
Func<string, string?> installedVersion,
|
||||||
|
Func<IUpdateChannel> whenOwned)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(channel);
|
||||||
|
ArgumentNullException.ThrowIfNull(installedVersion);
|
||||||
|
ArgumentNullException.ThrowIfNull(whenOwned);
|
||||||
|
|
||||||
|
var package = PackageFor(channel);
|
||||||
|
|
||||||
|
return installedVersion(package) is null
|
||||||
|
? new UnavailableUpdateChannel(
|
||||||
|
$"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that "
|
||||||
|
+ "package here — and an RPM is the only kind of update it can fetch. Update it with the "
|
||||||
|
+ "package manager that installed it.")
|
||||||
|
: whenOwned();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
||||||
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
|
/// <see cref="UpdateChannels.ForThisMachine"/> and <see cref="ForInstall(string)"/> — a build run from
|
||||||
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
|
/// a checkout, or installed by something other than rpm, never reaches this class at all. The same
|
||||||
/// composition rather than at every call site.
|
/// split Velopack's side uses, where <c>IsInstalled</c> is checked once at composition rather than at
|
||||||
|
/// every call site.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool IsSupported => true;
|
public bool IsSupported => true;
|
||||||
|
|
||||||
@@ -184,9 +228,20 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ◆ No answer from rpm is a failure, never a version. ForInstall only built this class because
|
||||||
|
// rpm answered, so silence now is a package removed under a running process or an rpm that
|
||||||
|
// stopped working — and any stand-in number would make every published build look newer and
|
||||||
|
// fetch a package for a machine that cannot install it, which is what this once did.
|
||||||
|
if (installedVersion(packageName) is not { } installed)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"rpm has no record of {packageName}, so there is no installed version to compare the "
|
||||||
|
+ $"{channel} release against.");
|
||||||
|
}
|
||||||
|
|
||||||
// The one place null is returned, and it means what null is documented to mean: this build is
|
// The one place null is returned, and it means what null is documented to mean: this build is
|
||||||
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
||||||
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
|
if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -324,6 +379,12 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
||||||
: $"{RepositoryApi}/releases/latest";
|
: $"{RepositoryApi}/releases/latest";
|
||||||
|
|
||||||
|
/// <summary>The package a channel's builds are published as.</summary>
|
||||||
|
private static string PackageFor(string channel) =>
|
||||||
|
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||||
|
? "dodossh-nightly"
|
||||||
|
: "dodossh";
|
||||||
|
|
||||||
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
||||||
release?.Assets?.FirstOrDefault(
|
release?.Assets?.FirstOrDefault(
|
||||||
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
||||||
@@ -365,9 +426,11 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
|
/// A machine that cannot answer gets null — no <c>rpm</c> at all, or one with no such package — and
|
||||||
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
|
/// null means "rpm does not own this copy", never a version. It used to be read as <c>0</c>, on the
|
||||||
/// an install PackageKit declines rather than a security fix that never arrives.
|
/// argument that the worst case was an install PackageKit declined. That holds only where PackageKit
|
||||||
|
/// installs RPMs: on any other distribution it made every published build look newer, forever, and
|
||||||
|
/// fetched a package that machine could not install. See <see cref="ForInstall(string)"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string? InstalledRpmVersion(string packageName)
|
private static string? InstalledRpmVersion(string packageName)
|
||||||
|
|||||||
@@ -66,21 +66,23 @@ internal static class UpdateChannels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
/// <summary>The Linux channel, if this copy is one the RPM put here.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
||||||
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
/// lives and then asking rpm.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the
|
||||||
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
/// spec puts it there — so a build running from anywhere else is a checkout somebody started, an
|
||||||
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
/// extracted archive, or a tree copied by hand. None of those can be updated by installing a package
|
||||||
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
/// over them, and offering it would be an offer that cannot be carried out.
|
||||||
/// out.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
/// <b>The path is necessary and not sufficient.</b> It used to be the whole check, on the argument
|
||||||
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
/// that asking rpm cost a process launch for a distinction nothing acted on differently. Something
|
||||||
/// differently. The install root is a constant in two places — here and the packaging script — and
|
/// does: a locally built pacman package that mirrors the spec puts the same tree at the same path,
|
||||||
/// they are named in each other's comments.
|
/// and that copy was offered every nightly as an RPM it could never install. So a matching path is
|
||||||
|
/// then put to rpm — see <see cref="LinuxUpdateChannel.ForInstall(string)"/> — and the launch is paid
|
||||||
|
/// only where the path already matched. The install root is a constant in two places — here and the
|
||||||
|
/// packaging script — and they are named in each other's comments.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[SupportedOSPlatform("linux")]
|
[SupportedOSPlatform("linux")]
|
||||||
@@ -91,7 +93,7 @@ internal static class UpdateChannels
|
|||||||
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
return string.Equals(here, root, StringComparison.Ordinal)
|
return string.Equals(here, root, StringComparison.Ordinal)
|
||||||
? new LinuxUpdateChannel(DesktopChannel.Name)
|
? LinuxUpdateChannel.ForInstall(DesktopChannel.Name)
|
||||||
: new UnavailableUpdateChannel();
|
: new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.ConnectPrompt"
|
||||||
|
x:DataType="vm:MainWindowViewModel"
|
||||||
|
Background="{StaticResource Pane}">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
What the terminal's rectangle holds when Terminal is pressed with nothing open: a prompt, because asking
|
||||||
|
is what a terminal with nothing in it does. It used to be an empty frame, with the tab row's "+" the only
|
||||||
|
thing on it that did anything.
|
||||||
|
|
||||||
|
Two lines, in the order ssh itself asks. The first takes a saved host's name — matched exactly, with the
|
||||||
|
names that match so far printed under it for Tab or a click to take — or user@host[:port] for a machine
|
||||||
|
that is not saved. The second appears only when a password is needed, worded as ssh words it, and is
|
||||||
|
never offered for a host whose keychain binding supplies its own credential.
|
||||||
|
|
||||||
|
Drawn by Avalonia in the WebView's own rectangle, which the occlusion rule allows only because the two
|
||||||
|
are exclusive: this shows while there are no tabs, and the WebView only for a selected tab with a
|
||||||
|
session. See MainWindowViewModel.IsConnectPromptShowing.
|
||||||
|
|
||||||
|
Everything it does goes through the vault's own connect paths — ConnectToHostAsync for a saved host,
|
||||||
|
ConnectToEndpointAsync for an address — so every refusal those make is made here too, and nothing typed
|
||||||
|
at this prompt is written to the keychain.
|
||||||
|
-->
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<StackPanel Margin="18,16" Spacing="3" TextElement.FontFamily="{StaticResource MonoFont}">
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||||
|
Text="No session open. Connect to a saved host by name, or to any machine as user@host[:port]." />
|
||||||
|
|
||||||
|
<!-- ============ what to connect to ============ -->
|
||||||
|
<Grid ColumnDefinitions="Auto,*" Margin="0,14,0,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||||
|
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The saved hosts that match so far, best first, the first one Tab's. A wrapped row rather than a list,
|
||||||
|
because this is a shell's completion line and not a picker: a name and an address, nothing to scroll.
|
||||||
|
-->
|
||||||
|
<ItemsControl ItemsSource="{Binding PromptMatches}" Margin="0,2,0,0"
|
||||||
|
IsVisible="{Binding ShowsPromptMatches}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||||
|
<Button Classes="flat" Focusable="False" Padding="6,2" Margin="0,0,6,0" CornerRadius="5"
|
||||||
|
Cursor="Hand" ToolTip.Tip="{Binding Address}"
|
||||||
|
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextDim}"
|
||||||
|
Text="{Binding Label}" />
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- ============ the password, when one is needed ============ -->
|
||||||
|
<Grid x:Name="PasswordLine" ColumnDefinitions="Auto,*" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="13" Foreground="{StaticResource Text}"
|
||||||
|
Text="{Binding PromptPasswordLabel}" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1" x:Name="Password" Classes="prompt" Margin="8,0,0,0" PasswordChar="•"
|
||||||
|
Text="{Binding PromptPassword}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||||
|
Foreground="{StaticResource WarnText}"
|
||||||
|
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="10.5" Margin="0,10,0,0" Foreground="{StaticResource TextGhost}"
|
||||||
|
Text="ENTER to connect · TAB to complete · ESC to go back · CTRL+K to search hosts" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
============ where you have been ============
|
||||||
|
|
||||||
|
The machines most recently connected to, newest first — the vault's own connection log, deduplicated
|
||||||
|
by address, so it survives a restart and arrives on a new machine with the keychain. A click puts the
|
||||||
|
row on the prompt line and carries on from there exactly as if it had been typed: a saved host
|
||||||
|
connects, or asks for its password; an address asks for its password.
|
||||||
|
-->
|
||||||
|
<StackPanel Margin="0,22,0,0" Spacing="1" IsVisible="{Binding HasRecentConnections}">
|
||||||
|
<TextBlock Classes="mono" FontSize="11" Foreground="{StaticResource TextFaint}" Text="recent"
|
||||||
|
Margin="0,0,0,4" />
|
||||||
|
<ItemsControl ItemsSource="{Binding RecentConnections}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||||
|
<Button Classes="flat" Focusable="False" Padding="6,3" CornerRadius="5" Cursor="Hand"
|
||||||
|
HorizontalAlignment="Left"
|
||||||
|
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="14">
|
||||||
|
<TextBlock Classes="mono" FontSize="12.5" Foreground="{StaticResource TextDim}"
|
||||||
|
Text="{Binding Address}" />
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}"
|
||||||
|
Text="{Binding HostLabel}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>The empty terminal's prompt: what to connect to, and its password when one is needed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The keys and the caret live here, as the palette's do in <see cref="QuickConnect"/> and for its reason: a
|
||||||
|
/// <see cref="UserControl"/> hosts in a bare window under the headless harness, and the window itself cannot
|
||||||
|
/// be shown there at all.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class ConnectPrompt : UserControl
|
||||||
|
{
|
||||||
|
public ConnectPrompt()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
// Tunnelled, for the reason QuickConnect gives: the prompt owns Enter, Escape and Tab while the caret is
|
||||||
|
// in it, and a text box on the route must not get to them first.
|
||||||
|
AddHandler(KeyDownEvent, OnPromptKey, RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
|
// The password line appearing, or going, moves the caret with it: Enter on a name that needs a password
|
||||||
|
// should leave somebody typing the password, and Escape off it should leave them editing the name.
|
||||||
|
PasswordLine.PropertyChanged += OnLineChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The first line's box, for the caret and for the tests.</summary>
|
||||||
|
internal TextBox TargetBox => Target;
|
||||||
|
|
||||||
|
/// <summary>The password line's box, for the caret and for the tests.</summary>
|
||||||
|
internal TextBox PasswordBox => Password;
|
||||||
|
|
||||||
|
private MainWindowViewModel? Shell => DataContext as MainWindowViewModel;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||||
|
{
|
||||||
|
base.OnPropertyChanged(change);
|
||||||
|
|
||||||
|
// Posted, for the reason QuickConnect.TakeKeyboard gives at length: at the instant this becomes
|
||||||
|
// visible its boxes are not in the visual tree yet, and focus is refused to anything that is not.
|
||||||
|
if (change.Property == IsVisibleProperty && change.GetNewValue<bool>())
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnLineChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Property == IsVisibleProperty && IsVisible)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.Post(TakeKeyboard, DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts the caret on whichever line is asking.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves, for the reason <see cref="NativeKeyboardFocus"/> gives: the terminal's WebView can still
|
||||||
|
/// hold the native window's focus after it has been collapsed, and an Avalonia focus without the native
|
||||||
|
/// call is a caret that receives nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private void TakeKeyboard()
|
||||||
|
{
|
||||||
|
if (!IsEffectivelyVisible)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (TopLevel.GetTopLevel(this) is Window window)
|
||||||
|
{
|
||||||
|
NativeKeyboardFocus.ReturnTo(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PasswordLine.IsVisible)
|
||||||
|
{
|
||||||
|
Password.Focus();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Target.Focus();
|
||||||
|
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPromptKey(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (Shell is not { IsConnectPromptShowing: true } shell || e.KeyModifiers != KeyModifiers.None)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (e.Key)
|
||||||
|
{
|
||||||
|
case Key.Enter:
|
||||||
|
// Fire-and-forget, as the palette's is: the command reports its own refusals on the prompt,
|
||||||
|
// and a connection's progress and failure in the tab it opens.
|
||||||
|
_ = shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
e.Handled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.Escape:
|
||||||
|
shell.CancelPromptCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case Key.Tab:
|
||||||
|
// Only when there is something to complete to; otherwise Tab moves the focus, as it always does.
|
||||||
|
e.Handled = shell.CompletePrompt();
|
||||||
|
|
||||||
|
if (e.Handled)
|
||||||
|
{
|
||||||
|
Target.CaretIndex = Target.Text?.Length ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -707,8 +707,8 @@
|
|||||||
◆ THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
|
◆ 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
|
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
|
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
|
ticked. No credential is chosen here: the host is reached as whoever this device signed in to
|
||||||
and synced like every other secret. See VaultViewModel.Spelunk.cs.
|
that Spelunk as, and the card says who, or offers to sign in. See VaultViewModel.Spelunk.cs.
|
||||||
-->
|
-->
|
||||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||||
@@ -724,25 +724,20 @@
|
|||||||
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
|
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
|
||||||
PlaceholderText="Spelunk server id" />
|
PlaceholderText="Spelunk server id" />
|
||||||
<!--
|
<!--
|
||||||
Saved Spelunk keys first (Settings → Security), each qualified by the one address it
|
Whether this device can reach that Spelunk, and as whom; or a button to sign in, which
|
||||||
goes to, then credentials whose password is a key. A deleted one keeps a placeholder, as
|
opens the browser and waits there. Nothing about it is saved with the host.
|
||||||
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" />
|
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
|
||||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
Text="{Binding EditorSpelunkSignInNote}"
|
||||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
IsVisible="{Binding EditorSpelunkSignInNote, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
HorizontalAlignment="Stretch" Height="40">
|
<StackPanel Orientation="Horizontal" Spacing="8" IsVisible="{Binding EditorSpelunkNeedsSignIn}">
|
||||||
<ComboBox.ItemTemplate>
|
<Button Classes="accent" Height="34" Content="SIGN IN TO SPELUNK"
|
||||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
Command="{Binding SignInToEditorSpelunkCommand}"
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
IsVisible="{Binding !IsSigningInToSpelunk}" />
|
||||||
<TextBlock Text="{Binding Label}" />
|
<Button Classes="ghost" Height="34" Content="CANCEL"
|
||||||
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
|
Command="{Binding SignInToEditorSpelunkCancelCommand}"
|
||||||
VerticalAlignment="Center" />
|
IsVisible="{Binding IsSigningInToSpelunk}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</DataTemplate>
|
|
||||||
</ComboBox.ItemTemplate>
|
|
||||||
</ComboBox>
|
|
||||||
<!--
|
<!--
|
||||||
Spelunk signs the user in: a key pair made in memory for each connection, its public
|
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
|
half sent with the ticket, and the agent's one-connection SSH server accepting only
|
||||||
@@ -754,12 +749,12 @@
|
|||||||
</CheckBox>
|
</CheckBox>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<!--
|
<!--
|
||||||
What the route asks for and what it costs, said where the decision is made: an Admin-role
|
What the route asks for and what it costs, said where the decision is made: this device
|
||||||
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
|
signed in to that Spelunk, DodoSSH online to vouch for it, and a host that is reached only
|
||||||
only while Spelunk and its agent are.
|
while Spelunk and its agent are.
|
||||||
-->
|
-->
|
||||||
<TextBlock Classes="hint" FontSize="11.5"
|
<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." />
|
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. You connect as yourself in Spelunk, which takes the Admin role on the server, and DodoSSH has to be online to check your account first. 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>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -285,9 +285,8 @@
|
|||||||
<!--
|
<!--
|
||||||
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
||||||
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
||||||
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
used to print ("no terminals open · press + or Ctrl+K…") went with it, and the surface with
|
||||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
no tab open is answered by the prompt below instead — see ConnectPrompt.axaml.
|
||||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
|
||||||
-->
|
-->
|
||||||
<!--
|
<!--
|
||||||
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
Three rows since the disconnect strip arrived, where v5c-4 left two. The extra one is Auto
|
||||||
@@ -309,6 +308,16 @@
|
|||||||
<views:ConnectingCard x:Name="ConnectingPane"
|
<views:ConnectingCard x:Name="ConnectingPane"
|
||||||
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
IsVisible="{Binding IsConnectingShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The third: nothing open at all, which is what pressing Terminal on the rail with no
|
||||||
|
session reaches. A prompt asking what to connect to, where an empty frame used to be.
|
||||||
|
Exclusive with the WebView for the same reason the card above is — no tab, no session —
|
||||||
|
and FallbackValue=False for the reason the WebView's own binding gives. See
|
||||||
|
ConnectPrompt.axaml.
|
||||||
|
-->
|
||||||
|
<views:ConnectPrompt x:Name="PromptPane"
|
||||||
|
IsVisible="{Binding IsConnectPromptShowing, FallbackValue=False}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate
|
||||||
browser process tree, so twenty tabs would cost twenty of them.
|
browser process tree, so twenty tabs would cost twenty of them.
|
||||||
|
|||||||
@@ -170,7 +170,7 @@
|
|||||||
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
|
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
|
||||||
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
|
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
|
||||||
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
|
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
|
||||||
and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in.
|
and VaultViewModel.ConnectToEndpointAsync for why a typed password is the only way in.
|
||||||
-->
|
-->
|
||||||
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
|
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,10 @@
|
|||||||
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||||
IsVisible="{Binding Updates.IsUnsupported}" />
|
IsVisible="{Binding Updates.IsUnsupportedForTheUsualReason}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="{Binding Updates.UnsupportedReason}"
|
||||||
|
IsVisible="{Binding Updates.UnsupportedReason, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,12 @@
|
|||||||
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
|
SPELUNK is where this device signs in to a Spelunk: an address and SIGN IN, which opens the browser at
|
||||||
personal vault (VaultViewModel.SaveSpelunkKeyCommand) and synced like every secret. The address is the
|
Spelunk and waits for it (VaultViewModel.SignInToSpelunkCommand, cancellable while it waits). What comes
|
||||||
point — a key saved here is only ever sent there, and links from that Spelunk connect straight away
|
back stays on this device — it is not a keychain item and does not sync — and links from that Spelunk
|
||||||
because saving it is the user saying so. A second key for an address already saved replaces the first.
|
connect straight away because signing in is the user saying so. Signing in again to an address replaces
|
||||||
The key box is a password box and is emptied the moment the key is in the keychain; the list below it
|
the sign-in. The list below shows who each sign-in is and how many hosts reach Spelunk through it, never
|
||||||
shows the name, the address and how many hosts use each key, never the key. Deleting goes through the
|
its token, and SIGN OUT ends it in Spelunk and here.
|
||||||
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
|
||||||
@@ -115,76 +114,66 @@
|
|||||||
|
|
||||||
<TextBlock Classes="settingssection" Text="SPELUNK" />
|
<TextBlock Classes="settingssection" Text="SPELUNK" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Signing this device in to a Spelunk. Spelunk asks in the browser, however its person signs in there,
|
||||||
|
and what it gives back stays on this device: each device signs in on its own, and signing out of
|
||||||
|
DodoSSH signs out of every Spelunk too. See VaultViewModel.SpelunkSignIns.cs.
|
||||||
|
-->
|
||||||
<Border Classes="settingscard">
|
<Border Classes="settingscard">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
<Border Classes="settingsrow">
|
<Border Classes="settingsrow">
|
||||||
<StackPanel Spacing="10">
|
<StackPanel Spacing="10">
|
||||||
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
|
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
|
||||||
<TextBlock Classes="settingsrowtitle" Text="Spelunk API key" />
|
<TextBlock Classes="settingsrowtitle" Text="Sign in to Spelunk" />
|
||||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
<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." />
|
Text="Hosts reached through Spelunk connect as you, in every organisation you are in there, and links from that Spelunk connect straight away. Spelunk asks in your browser. The sign-in stays on this device and ends when you sign out here, in Spelunk, or of DodoSSH." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="10"
|
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10"
|
||||||
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkKeyFormApi}"
|
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkSignInFormApi}"
|
||||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
PlaceholderText="Spelunk API address (https://api.…)">
|
||||||
<TextBox Grid.Column="1" Height="38" PasswordChar="•" Text="{Binding Vault.SpelunkKeyFormKey}"
|
|
||||||
PlaceholderText="API key (spelunk_…)">
|
|
||||||
<TextBox.KeyBindings>
|
<TextBox.KeyBindings>
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveSpelunkKeyCommand}" />
|
<KeyBinding Gesture="Enter" Command="{Binding Vault.SignInToSpelunkCommand}" />
|
||||||
</TextBox.KeyBindings>
|
</TextBox.KeyBindings>
|
||||||
</TextBox>
|
</TextBox>
|
||||||
<Button Grid.Column="2" Classes="accent" Height="38" Content="SAVE KEY"
|
<Button Grid.Column="1" Classes="accent" Height="38" Content="SIGN IN"
|
||||||
Command="{Binding Vault.SaveSpelunkKeyCommand}"
|
Command="{Binding Vault.SignInToSpelunkCommand}"
|
||||||
IsEnabled="{Binding !Vault.IsBusy, FallbackValue=False}" />
|
IsVisible="{Binding !Vault.IsSigningInToSpelunk, FallbackValue=True}" />
|
||||||
|
<Button Grid.Column="1" Classes="ghost" Height="38" Content="CANCEL"
|
||||||
|
Command="{Binding Vault.SignInToSpelunkCancelCommand}"
|
||||||
|
IsVisible="{Binding Vault.IsSigningInToSpelunk, FallbackValue=False}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
Text="{Binding Vault.SpelunkKeyFormNote}"
|
Text="{Binding Vault.SpelunkSignInFormNote}"
|
||||||
IsVisible="{Binding Vault.HasSpelunkKeyFormNote, FallbackValue=False}" />
|
IsVisible="{Binding Vault.HasSpelunkSignInFormNote, FallbackValue=False}" />
|
||||||
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
|
||||||
Text="Unlock your keychain to save a Spelunk key." />
|
Text="Unlock your keychain to sign in to Spelunk." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- The delete confirmation, in place, as the tags and groups pages draw it. -->
|
<ItemsControl ItemsSource="{Binding Vault.SpelunkSignIns}">
|
||||||
<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>
|
<ItemsControl.ItemsPanel>
|
||||||
<ItemsPanelTemplate>
|
<ItemsPanelTemplate>
|
||||||
<StackPanel />
|
<StackPanel />
|
||||||
</ItemsPanelTemplate>
|
</ItemsPanelTemplate>
|
||||||
</ItemsControl.ItemsPanel>
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:SpelunkKeyRowViewModel">
|
<DataTemplate x:DataType="vm:SpelunkSignInRowViewModel">
|
||||||
<Border Classes="settingsrow" Padding="24,14">
|
<Border Classes="settingsrow" Padding="24,14">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
|
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
|
||||||
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<TextBlock Classes="settingsrowtitle" Text="{Binding Who}" />
|
||||||
<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}"
|
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
|
||||||
Text="{Binding ApiBase}" />
|
Text="{Binding ApiBase}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
|
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
|
||||||
Text="{Binding UsedBy}" />
|
Text="{Binding UsedBy}" />
|
||||||
<Button Grid.Column="2" Classes="paneicon danger" Width="30" Height="30" FontSize="13"
|
<Button Grid.Column="2" Classes="ghost" Height="30" Content="SIGN OUT"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteSpelunkKeyRowCommand}"
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.SignOutOfSpelunkCommand}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
ToolTip.Tip="Delete this key. Hosts that reach Spelunk with it stop connecting until they name another.">
|
ToolTip.Tip="Sign this device out of this Spelunk. Hosts reached through it stop connecting until you sign in again." />
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ using Avalonia.Controls;
|
|||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys.
|
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and signing in to Spelunk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed partial class SettingsSecurityPage : UserControl
|
internal sealed partial class SettingsSecurityPage : UserControl
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -209,10 +209,9 @@
|
|||||||
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
|
design's, unlike HOST/BUCKET across the arrow column, which keeps its own words for a reason
|
||||||
recorded where it is drawn.
|
recorded where it is drawn.
|
||||||
|
|
||||||
UP is arrow_upward here and arrow_downward on the host/bucket pane below, matching the design's own
|
UP is arrow_upward on both panes. The design drew arrow_downward on the host/bucket pane, and that
|
||||||
two glyphs exactly rather than picking one "up a directory" icon for both — both buttons do the same
|
was carried over until it was reported as a wrong icon — which it is: both buttons go to the parent
|
||||||
thing (go to the parent directory), and the pair reading differently is the design's own choice
|
directory, and a down arrow under the tooltip "Up one directory" says the opposite of what it does.
|
||||||
faithfully carried over rather than a functional difference invented to justify it.
|
|
||||||
-->
|
-->
|
||||||
<!--
|
<!--
|
||||||
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
◆ THE PATH SITS IN THE STAR COLUMN, ALONE, AND THAT PLACEMENT IS LOAD-BEARING.
|
||||||
@@ -398,8 +397,8 @@
|
|||||||
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
|
directory tree and a flat namespace with inferred folders — and that reason still holds, so only
|
||||||
the style moves to the design's 10px tracked label, not the wording.
|
the style moves to the design's 10px tracked label, not the wording.
|
||||||
|
|
||||||
UP is arrow_downward here, matching arrow_upward on the local pane above per the design's own two
|
UP is arrow_upward, the same as the local pane's; see that pane's own remark for why it is no longer
|
||||||
distinct glyphs; see that pane's own remark on why the pair differs without the actions differing.
|
the design's arrow_downward.
|
||||||
-->
|
-->
|
||||||
<!--
|
<!--
|
||||||
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
◆ THE PATH IS TYPED HERE, and that is what the star column is now holding.
|
||||||
@@ -446,7 +445,7 @@
|
|||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" Margin="8,0,0,0">
|
||||||
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
<Button Classes="paneicon" Command="{Binding RemoteUpCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Up one directory">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="16" Text="" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
<Button Classes="paneicon" Command="{Binding RefreshRemoteCommand}"
|
||||||
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
IsEnabled="{Binding IsConnected}" ToolTip.Tip="Refresh">
|
||||||
@@ -546,7 +545,7 @@
|
|||||||
</Button>
|
</Button>
|
||||||
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
<Button Classes="paneicon" Command="{Binding RenameRemoteCommand}"
|
||||||
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
IsEnabled="{Binding CanRenameRemote}" ToolTip.Tip="Rename on the host">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" Text="" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
<Button Classes="paneicon" Command="{Binding MoveRemoteCommand}"
|
||||||
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
IsEnabled="{Binding CanMoveRemote}" ToolTip.Tip="Move to another directory">
|
||||||
|
|||||||
@@ -145,18 +145,26 @@ public static class HostSecretCodec
|
|||||||
/// shows the host, refuses to edit it, and says to update.
|
/// shows the host, refuses to edit it, and says to update.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Only a route that signs in is written here. Every other Spelunk host stays at
|
/// No longer written: every Spelunk host is written at <see cref="SpelunkSignedInDeviceSchemaVersion"/>.
|
||||||
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int SpelunkSignInSchemaVersion = 10;
|
public const int SpelunkSignInSchemaVersion = 10;
|
||||||
|
|
||||||
|
/// <summary>The version from which a <see cref="SpelunkRoute"/> names no API key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every Spelunk host is written here: the device's own sign-in to Spelunk replaced the key a route used
|
||||||
|
/// to name. A build that knows 10 requires that key and would read the host as damaged; written at this
|
||||||
|
/// version, it shows the host, refuses to edit it, and says to update. A payload written before still
|
||||||
|
/// decodes — its key id is an unknown member, skipped like any other.
|
||||||
|
/// </remarks>
|
||||||
|
public const int SpelunkSignedInDeviceSchemaVersion = 11;
|
||||||
|
|
||||||
/// <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 = SpelunkSignInSchemaVersion;
|
public const int CurrentSchemaVersion = SpelunkSignedInDeviceSchemaVersion;
|
||||||
|
|
||||||
/// <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>
|
||||||
@@ -292,12 +300,7 @@ public static class HostSecretCodec
|
|||||||
|
|
||||||
if (host.Spelunk is not null)
|
if (host.Spelunk is not null)
|
||||||
{
|
{
|
||||||
version = Math.Max(version, SpelunkSchemaVersion);
|
version = Math.Max(version, SpelunkSignedInDeviceSchemaVersion);
|
||||||
}
|
|
||||||
|
|
||||||
if (host.SignsInThroughSpelunk)
|
|
||||||
{
|
|
||||||
version = Math.Max(version, SpelunkSignInSchemaVersion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return version;
|
return version;
|
||||||
@@ -450,7 +453,6 @@ public static class HostSecretCodec
|
|||||||
{
|
{
|
||||||
ApiBase = parsed.Api ?? string.Empty,
|
ApiBase = parsed.Api ?? string.Empty,
|
||||||
ServerId = parsed.ServerId ?? Guid.Empty,
|
ServerId = parsed.ServerId ?? Guid.Empty,
|
||||||
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
|
|
||||||
|
|
||||||
// False folds to the default, as AsksForPassword's does: one state, one spelling.
|
// False folds to the default, as AsksForPassword's does: one state, one spelling.
|
||||||
SignInThroughSpelunk = parsed.SignIn is true,
|
SignInThroughSpelunk = parsed.SignIn is true,
|
||||||
@@ -544,19 +546,13 @@ internal sealed class SpelunkPayloadDocument
|
|||||||
|
|
||||||
public Guid? ServerId { get; set; }
|
public Guid? ServerId { get; set; }
|
||||||
|
|
||||||
public Guid? ApiKeyCredentialId { get; set; }
|
/// <remarks>Last, and null unless true. See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.</remarks>
|
||||||
|
|
||||||
/// <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 bool? SignIn { get; set; }
|
||||||
|
|
||||||
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
|
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
|
||||||
{
|
{
|
||||||
Api = route.ApiBase,
|
Api = route.ApiBase,
|
||||||
ServerId = route.ServerId,
|
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
|
// 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.
|
// field existed — the rule AsksForPassword follows.
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,7 @@ using System.Diagnostics.CodeAnalysis;
|
|||||||
namespace DodoSSH.Client.Domain;
|
namespace DodoSSH.Client.Domain;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way.
|
/// How a host is reached through Spelunk: which Spelunk, and which of its servers.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -15,15 +15,15 @@ namespace DodoSSH.Client.Domain;
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Inside the host's encrypted payload, and nowhere else.</b> Unlike the relay, nothing about this has to
|
/// <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
|
/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, as whoever is
|
||||||
/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this.
|
/// signed in to that Spelunk on this device. So the concession ADR 0004 argues for the relay's address is
|
||||||
|
/// not made for this.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names the keychain item
|
/// <b>No credential travels with the host.</b> Each device signs in to a Spelunk once, through the browser,
|
||||||
/// holding it, for the reason <see cref="HostSecret.CredentialId"/> gives: one Spelunk key is the key for
|
/// and keeps what that gives it to itself (see <c>SpelunkSignIns</c>): the same host opens on every device
|
||||||
/// every server in its organisation, and a copy per host is twenty places to rotate it and one to forget.
|
/// whose person is signed in to its Spelunk, as that person, in whichever of their organisations the server
|
||||||
/// That item is a <see cref="SpelunkKeySecret"/>, which also says the one address the key may go to, or —
|
/// is in. Nothing about who may connect is synced.
|
||||||
/// for a host set up before those existed — an ordinary credential whose password is the key.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed record SpelunkRoute
|
public sealed record SpelunkRoute
|
||||||
@@ -50,32 +50,13 @@ public sealed record SpelunkRoute
|
|||||||
/// <summary>The server's id in Spelunk.</summary>
|
/// <summary>The server's id in Spelunk.</summary>
|
||||||
public required Guid ServerId { get; init; }
|
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>
|
/// <summary>
|
||||||
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// For each connection this client makes a throwaway key pair in memory and sends the public half with
|
/// 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 ticket request, which this device's sign-in to Spelunk 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
|
/// 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,
|
/// 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.
|
/// and the key is gone when the connection is.
|
||||||
@@ -121,12 +102,6 @@ public sealed record SpelunkRoute
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ApiKeyCredentialId == Guid.Empty)
|
|
||||||
{
|
|
||||||
reason = "A host reached through Spelunk needs its Spelunk API key chosen.";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
reason = null;
|
reason = null;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -136,8 +111,8 @@ public sealed record SpelunkRoute
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>HTTPS, or plain HTTP to this machine only.</b> The API key travels in a header on every tunnel, so
|
/// <b>HTTPS, or plain HTTP to this machine only.</b> The sign-in's token travels in a header on every
|
||||||
/// an address that sends it in clear across a network is refused rather than stored. A Spelunk running
|
/// 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
|
/// 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.
|
/// feature can be tried against one.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -175,7 +150,7 @@ public sealed record SpelunkRoute
|
|||||||
|
|
||||||
if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback)
|
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.";
|
reason = "The Spelunk API address must use https, because your sign-in is sent with every connection.";
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,9 +141,32 @@ public interface IUpdateChannel
|
|||||||
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
||||||
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One situation outside those four carries a <see cref="Reason"/>.</b> A Linux copy sitting exactly
|
||||||
|
/// where the RPM puts it that no RPM owns — built locally and installed by another distribution's package
|
||||||
|
/// manager. It looks installed, so the sentence about checkouts and unzipped copies would describe the
|
||||||
|
/// wrong machine, and the person reading it needs telling which updater is theirs.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
||||||
{
|
{
|
||||||
|
/// <summary>Creates one for the situations that need no telling apart.</summary>
|
||||||
|
public UnavailableUpdateChannel()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates one whose situation is worth a sentence of its own.</summary>
|
||||||
|
/// <param name="reason">What to tell the user, as a sentence they can act on.</param>
|
||||||
|
public UnavailableUpdateChannel(string reason)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||||
|
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why this copy cannot replace itself, where that is not the usual why.</summary>
|
||||||
|
public string? Reason { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsSupported => false;
|
public bool IsSupported => false;
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ internal sealed class RefreshingAccessTokenProvider(
|
|||||||
{
|
{
|
||||||
private readonly SemaphoreSlim gate = new(1, 1);
|
private readonly SemaphoreSlim gate = new(1, 1);
|
||||||
private TokenSet tokens = initial;
|
private TokenSet tokens = initial;
|
||||||
|
private DateTimeOffset lastRefreshedAt = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The refresh token this provider currently holds, or null when none was granted.
|
/// The refresh token this provider currently holds, or null when none was granted.
|
||||||
@@ -82,6 +83,7 @@ internal sealed class RefreshingAccessTokenProvider(
|
|||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
Volatile.Write(ref tokens, refreshed);
|
Volatile.Write(ref tokens, refreshed);
|
||||||
|
lastRefreshedAt = clock.GetUtcNow();
|
||||||
|
|
||||||
return refreshed.AccessToken;
|
return refreshed.AccessToken;
|
||||||
}
|
}
|
||||||
@@ -91,6 +93,44 @@ internal sealed class RefreshingAccessTokenProvider(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refreshes now, whatever the expiry says, unless that was done within <paramref name="within"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The refresh grant is the one question the identity provider answers about whether a sign-in still
|
||||||
|
/// stands: a disabled account, a revoked session or a removed user gets <c>invalid_grant</c> here, where
|
||||||
|
/// an access token already issued would go on being accepted until it expired. Under the same lock as the
|
||||||
|
/// ordinary refresh, so the two cannot spend one rotating refresh token twice.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="OidcException">The provider refused, or could not be asked.</exception>
|
||||||
|
internal async Task RefreshNowAsync(TimeSpan within, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var now = clock.GetUtcNow();
|
||||||
|
if (now - lastRefreshedAt < within)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = Volatile.Read(ref tokens);
|
||||||
|
if (current.RefreshToken is null)
|
||||||
|
{
|
||||||
|
throw new OidcException("This sign-in has no refresh token, so it cannot be checked. Sign in again.", "invalid_grant");
|
||||||
|
}
|
||||||
|
|
||||||
|
var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken).ConfigureAwait(false);
|
||||||
|
Volatile.Write(ref tokens, refreshed);
|
||||||
|
lastRefreshedAt = now;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
gate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public void Dispose() => gate.Dispose();
|
public void Dispose() => gate.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,6 +201,19 @@ public interface IVaultServer : IDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
IVaultEventStream Events { get; }
|
IVaultEventStream Events { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asks the identity provider, now, whether this sign-in still stands — at most once a minute.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For what must not go on working for a person the account has lost: connecting through Spelunk asks
|
||||||
|
/// before every tunnel, so a removed or disabled account stops it within a minute rather than when an
|
||||||
|
/// access token happens to expire. A stand-in server answers yes, which is why this has a body.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="DodoSSH.Client.Auth.OidcException">
|
||||||
|
/// The provider refused (<c>invalid_grant</c>), or could not be asked.
|
||||||
|
/// </exception>
|
||||||
|
Task VerifySignInAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reaches a host through the server, for a host set to connect that way.
|
/// Reaches a host through the server, for a host set to connect that way.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -261,6 +314,10 @@ public sealed class ServerConnection : IVaultServer
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IAccountApi Account => Api;
|
public IAccountApi Account => Api;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task VerifySignInAsync(CancellationToken cancellationToken) =>
|
||||||
|
tokens.RefreshNowAsync(TimeSpan.FromMinutes(1), cancellationToken);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ISyncApi Sync => Api;
|
public ISyncApi Sync => Api;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,447 @@
|
|||||||
|
using System.Buffers.Text;
|
||||||
|
using System.Globalization;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using DodoSSH.Client.Auth;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
/// <summary>A sign-in to Spelunk that did not happen; the message says why, in words for the user.</summary>
|
||||||
|
public sealed class SpelunkSignInException : Exception
|
||||||
|
{
|
||||||
|
public SpelunkSignInException()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SpelunkSignInException(string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public SpelunkSignInException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signs this device in to a Spelunk, and out again.</summary>
|
||||||
|
public interface ISpelunkAccounts
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Signs this device in to the Spelunk at <paramref name="apiBase"/> through the browser, and returns what
|
||||||
|
/// to keep. Nothing is kept here; the caller stores the result.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||||
|
/// <param name="cancellationToken">Abandons the sign-in — the user closing the dialog that waits for it.</param>
|
||||||
|
/// <exception cref="SpelunkSignInException">It did not happen; the message says why.</exception>
|
||||||
|
Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Tells Spelunk this device is signing out, so its sign-in ends there too. Best effort: never throws.</summary>
|
||||||
|
Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The OAuth authorization code flow with PKCE for a native app (RFC 8252), against Spelunk as the
|
||||||
|
/// authorization server.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The browser opens Spelunk's <c>/api/app-sign-in/authorize</c>, which sends it on to Spelunk's own
|
||||||
|
/// consent page. There the person signs in however they sign in to Spelunk — a password, a passkey, their
|
||||||
|
/// company's single sign-on, a second factor — and allows this device. Spelunk sends the browser back to the
|
||||||
|
/// same redirect this app's own sign-in uses (a loopback address on a desktop, the app's scheme on a phone)
|
||||||
|
/// with a code, which is redeemed here with the PKCE verifier for a token.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Not <see cref="OidcClient"/>.</b> Spelunk is no OpenID provider: it has no discovery document, and
|
||||||
|
/// what it returns is a token for its tunnel route, not an identity. The pieces that matter for security —
|
||||||
|
/// the callback, PKCE, the state check — are the same ones, from <c>DodoSSH.Client.Auth</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The token does not expire. Spelunk ends it when its person signs it out there, changes their password
|
||||||
|
/// or second factors, has their sessions ended, or is removed; a ticket request then answers 401 and the
|
||||||
|
/// sign-in is forgotten (see <c>VaultViewModel</c>).
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class SpelunkAccounts : ISpelunkAccounts
|
||||||
|
{
|
||||||
|
/// <summary>What Spelunk knows this app as.</summary>
|
||||||
|
public const string ClientId = "dodossh";
|
||||||
|
|
||||||
|
/// <summary>The longest token kept. Far beyond Spelunk's; a bound on what goes into a header and the cache.</summary>
|
||||||
|
private const int MaxTokenLength = 512;
|
||||||
|
|
||||||
|
/// <summary>What every token Spelunk issues a signed-in app begins with, and how Spelunk tells it from an API key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Checked rather than assumed: a token without it is one Spelunk would read as something else, so an answer
|
||||||
|
/// carrying one is not Spelunk's app sign-in, whatever else it looks like.
|
||||||
|
/// </remarks>
|
||||||
|
internal const string TokenPrefix = "spelunkapp_";
|
||||||
|
|
||||||
|
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
|
||||||
|
private const int MaxProblemBytes = 16 * 1024;
|
||||||
|
|
||||||
|
/// <summary>How long signing out waits for Spelunk before forgetting the sign-in here regardless.</summary>
|
||||||
|
private static readonly TimeSpan SignOutTimeout = TimeSpan.FromSeconds(10);
|
||||||
|
|
||||||
|
/// <summary>The page the loopback listener shows once Spelunk has sent the browser back.</summary>
|
||||||
|
private const string CompletionHtml =
|
||||||
|
"""
|
||||||
|
<!doctype html><html><head><meta charset="utf-8"><title>DodoSSH</title></head>
|
||||||
|
<body style="font-family:system-ui;margin:4rem;text-align:center">
|
||||||
|
<h1>Done</h1><p>You can close this tab and return to DodoSSH.</p>
|
||||||
|
</body></html>
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Redirects are not followed. The code, its verifier and the token go to the address the user signed in
|
||||||
|
/// to and nowhere else, and a Spelunk that answered with a redirect would be asking for them to be sent on.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly HttpClient SharedHttp = new(
|
||||||
|
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false })
|
||||||
|
{
|
||||||
|
Timeout = TimeSpan.FromSeconds(30),
|
||||||
|
};
|
||||||
|
|
||||||
|
private readonly HttpClient http;
|
||||||
|
private readonly IBrowserLauncher browser;
|
||||||
|
private readonly Func<string, IAuthorizationCallback> callbackFactory;
|
||||||
|
private readonly TimeProvider clock;
|
||||||
|
private readonly string deviceName;
|
||||||
|
|
||||||
|
/// <param name="browser">Opens Spelunk's sign-in.</param>
|
||||||
|
/// <param name="callbackFactory">
|
||||||
|
/// What waits for Spelunk's answer: the same seam as <see cref="OidcClientOptions.CallbackFactory"/>, so a
|
||||||
|
/// phone answers through its own scheme.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="clock">For the wait's timeout.</param>
|
||||||
|
/// <param name="deviceName">
|
||||||
|
/// What Spelunk is to call this device, on its consent page and under Your account; the machine's name when
|
||||||
|
/// not given. A phone passes its model, since its machine name says nothing.
|
||||||
|
/// </param>
|
||||||
|
public SpelunkAccounts(
|
||||||
|
IBrowserLauncher browser,
|
||||||
|
Func<string, IAuthorizationCallback>? callbackFactory = null,
|
||||||
|
TimeProvider? clock = null,
|
||||||
|
string? deviceName = null)
|
||||||
|
: this(SharedHttp, browser, callbackFactory, clock, deviceName)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Sign-ins whose requests are supplied, for tests.</summary>
|
||||||
|
internal SpelunkAccounts(
|
||||||
|
HttpClient http,
|
||||||
|
IBrowserLauncher browser,
|
||||||
|
Func<string, IAuthorizationCallback>? callbackFactory = null,
|
||||||
|
TimeProvider? clock = null,
|
||||||
|
string? deviceName = null)
|
||||||
|
{
|
||||||
|
this.http = http;
|
||||||
|
this.browser = browser;
|
||||||
|
this.callbackFactory = callbackFactory ?? (path => new LoopbackCallbackListener(path));
|
||||||
|
this.clock = clock ?? TimeProvider.System;
|
||||||
|
this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How long the browser has to come back.</summary>
|
||||||
|
public TimeSpan BrowserTimeout { get; init; } = TimeSpan.FromMinutes(10);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
var pkce = PkcePair.Create();
|
||||||
|
var state = Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
|
||||||
|
|
||||||
|
// Disposed on the way out whatever happens, so the loopback port is closed once it has had its one
|
||||||
|
// answer - or none.
|
||||||
|
using var callback = callbackFactory("/callback");
|
||||||
|
|
||||||
|
// Read once and sent twice, byte for byte: Spelunk compares the token request's against the authorize one.
|
||||||
|
var redirectUri = callback.RedirectUri.ToString();
|
||||||
|
|
||||||
|
await OpenBrowserAsync(AuthorizeUrl(normalized, redirectUri, pkce.CodeChallenge, state), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var answer = await WaitForAnswerAsync(callback, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Checked before anything else is read: an answer to some other sign-in is no answer to this one.
|
||||||
|
if (!string.Equals(answer.State, state, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException("The browser came back from a different sign-in, so it was ignored. Try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answer.Error is { } error)
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException(string.Equals(error, "access_denied", StringComparison.Ordinal)
|
||||||
|
? "Signing in to Spelunk was cancelled in the browser."
|
||||||
|
: $"Spelunk refused the sign-in ({SpelunkTunnelRoute.Readable(answer.ErrorDescription) ?? SpelunkTunnelRoute.Readable(error)}).");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answer.Code is not { Length: > 0 } code)
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException("Spelunk sent the browser back without a sign-in. Try again.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return await RedeemAsync(normalized, code, pkce.CodeVerifier, redirectUri, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spelunk's authorize address for this sign-in: OAuth's own parameter names, and this device's.</summary>
|
||||||
|
private Uri AuthorizeUrl(string apiBase, string redirectUri, string codeChallenge, string state)
|
||||||
|
{
|
||||||
|
var query = string.Join('&', new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["client_id"] = ClientId,
|
||||||
|
["redirect_uri"] = redirectUri,
|
||||||
|
["response_type"] = "code",
|
||||||
|
["code_challenge"] = codeChallenge,
|
||||||
|
["code_challenge_method"] = PkcePair.Method,
|
||||||
|
["state"] = state,
|
||||||
|
["device_name"] = deviceName,
|
||||||
|
}.Select(pair => $"{pair.Key}={Uri.EscapeDataString(pair.Value)}"));
|
||||||
|
|
||||||
|
return new Uri($"{apiBase}/api/app-sign-in/authorize?{query}", UriKind.Absolute);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task OpenBrowserAsync(Uri authorize, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await browser.OpenAsync(authorize, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException))
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException($"The browser could not be opened to sign in to Spelunk: {exception.Message}", exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Waits for the browser to come back, and turns every way it does not into a sentence.</summary>
|
||||||
|
/// <exception cref="OperationCanceledException">The user abandoned the sign-in.</exception>
|
||||||
|
private async Task<CallbackResult> WaitForAnswerAsync(IAuthorizationCallback callback, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var timeout = new CancellationTokenSource(BrowserTimeout, clock);
|
||||||
|
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeout.Token);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await callback.WaitForCallbackAsync(CompletionHtml, linked.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (timeout.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException("Spelunk did not send the browser back in time. Try signing in again.");
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException exception)
|
||||||
|
{
|
||||||
|
// Neither the user nor the clock: a phone's redirect is handed to one sign-in at a time, and another
|
||||||
|
// one - to DodoSSH, or to Spelunk again - took it over.
|
||||||
|
throw new SpelunkSignInException("Another sign-in started before this one finished. Try signing in again.", exception);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// A phone's browser closing without an answer arrives as an OidcException with the reason in it; a
|
||||||
|
// desktop's listener failing to read the browser's request arrives as whatever the socket threw.
|
||||||
|
throw new SpelunkSignInException(
|
||||||
|
exception is OidcException ? exception.Message : $"The browser's answer from Spelunk could not be read: {exception.Message}",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<SpelunkSignInRecord> RedeemAsync(
|
||||||
|
string apiBase, string code, string verifier, string redirectUri, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Post, new Uri($"{apiBase}/api/app-sign-in/token", UriKind.Absolute))
|
||||||
|
{
|
||||||
|
Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
||||||
|
new SpelunkTokenRequestDocument { ClientId = ClientId, Code = code, CodeVerifier = verifier, RedirectUri = redirectUri },
|
||||||
|
SpelunkAccountsJsonContext.Default.SpelunkTokenRequestDocument))
|
||||||
|
{
|
||||||
|
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (HttpRequestException exception)
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException($"Spelunk could not be reached at {apiBase}: {exception.Message}", exception);
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException($"Spelunk at {apiBase} did not answer in time. Try again in a moment.", exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (response)
|
||||||
|
{
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException(await DescribeRefusalAsync(response, cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return await ReadSignInAsync(apiBase, response, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why the token endpoint said no, in Spelunk's own words where it gave some.</summary>
|
||||||
|
private static async Task<string> DescribeRefusalAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var detail = await ReadProblemDetailAsync(response, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return response.StatusCode switch
|
||||||
|
{
|
||||||
|
// Spelunk says which: a code that was wrong, used or expired, or sessions ended meanwhile.
|
||||||
|
HttpStatusCode.BadRequest => "Spelunk did not accept the sign-in"
|
||||||
|
+ (detail is null ? ". It may have taken too long; try again." : $": {detail}"),
|
||||||
|
|
||||||
|
HttpStatusCode.TooManyRequests =>
|
||||||
|
"Spelunk is refusing sign-ins from this address for a moment. Wait a minute, then try again.",
|
||||||
|
|
||||||
|
_ => string.Create(CultureInfo.InvariantCulture, $"Spelunk would not finish the sign-in (HTTP {(int)response.StatusCode})")
|
||||||
|
+ (detail is null ? "." : $": {detail}"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the token endpoint's answer, and refuses one that is not an app sign-in's.</summary>
|
||||||
|
private async Task<SpelunkSignInRecord> ReadSignInAsync(string apiBase, HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SpelunkTokenDocument? token;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
token = await response.Content
|
||||||
|
.ReadFromJsonAsync(SpelunkAccountsJsonContext.Default.SpelunkTokenDocument, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (JsonException exception)
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException(Unintelligible, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The token goes into a header on every ticket request, so it has to be one - and one Spelunk reads as
|
||||||
|
// an app's.
|
||||||
|
if (token is not { Token: { Length: > 0 and <= MaxTokenLength } value, SessionId: { } sessionId, User: { Email: { } email } user }
|
||||||
|
|| !value.StartsWith(TokenPrefix, StringComparison.Ordinal)
|
||||||
|
|| value.Length == TokenPrefix.Length
|
||||||
|
|| !value.All(character => char.IsAsciiLetterOrDigit(character) || character == '_'))
|
||||||
|
{
|
||||||
|
throw new SpelunkSignInException(Unintelligible);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpelunkSignInRecord(apiBase, value, sessionId, email, user.DisplayName ?? email, clock.GetUtcNow());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(signIn);
|
||||||
|
|
||||||
|
// Only to an address a sign-in could have come from: the cache is this device's own, but a record that
|
||||||
|
// no longer normalises to itself is one to forget rather than to send a token on behalf of.
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(signIn.ApiBase, out var apiBase, out _)
|
||||||
|
|| !string.Equals(apiBase, signIn.ApiBase, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded on its own: signing out of DodoSSH waits for this, and a Spelunk that does not answer must not
|
||||||
|
// hold that up for the transport's whole timeout.
|
||||||
|
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeout.CancelAfter(SignOutTimeout);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Delete, new Uri($"{apiBase}/api/app-sign-in/session", UriKind.Absolute));
|
||||||
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", signIn.Token);
|
||||||
|
using var response = await http.SendAsync(request, timeout.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is HttpRequestException or OperationCanceledException or InvalidOperationException or FormatException)
|
||||||
|
{
|
||||||
|
// Unreachable, already signed out, or slow: either way this device forgets it, which is what signing
|
||||||
|
// out here means. Spelunk's list under Your account still offers to end it there.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spelunk's own words about a refusal, cut to one line, or null when it gave none.</summary>
|
||||||
|
private static async Task<string?> ReadProblemDetailAsync(HttpResponseMessage response, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
if (body.Length is 0 or > MaxProblemBytes)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var problem = JsonSerializer.Deserialize(body, SpelunkAccountsJsonContext.Default.SpelunkSignInProblemDocument);
|
||||||
|
return SpelunkTunnelRoute.Readable(problem?.Detail);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is JsonException or HttpRequestException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string Unintelligible =
|
||||||
|
"Spelunk answered the sign-in in a way this app does not understand. Update DodoSSH, or check that the API "
|
||||||
|
+ "address is Spelunk's.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What the app sends to redeem its code.</summary>
|
||||||
|
internal sealed class SpelunkTokenRequestDocument
|
||||||
|
{
|
||||||
|
public string? ClientId { get; set; }
|
||||||
|
|
||||||
|
public string? Code { get; set; }
|
||||||
|
|
||||||
|
public string? CodeVerifier { get; set; }
|
||||||
|
|
||||||
|
public string? RedirectUri { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A signed-in app's token, as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
||||||
|
internal sealed class SpelunkTokenDocument
|
||||||
|
{
|
||||||
|
public string? Token { get; set; }
|
||||||
|
|
||||||
|
public Guid? SessionId { get; set; }
|
||||||
|
|
||||||
|
public SpelunkTokenUserDocument? User { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal sealed class SpelunkTokenUserDocument
|
||||||
|
{
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
public string? DisplayName { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The part of an RFC 7807 refusal from Spelunk's token endpoint that is shown.</summary>
|
||||||
|
internal sealed class SpelunkSignInProblemDocument
|
||||||
|
{
|
||||||
|
public string? Detail { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||||
|
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||||
|
[JsonSerializable(typeof(SpelunkTokenRequestDocument))]
|
||||||
|
[JsonSerializable(typeof(SpelunkTokenDocument))]
|
||||||
|
[JsonSerializable(typeof(SpelunkSignInProblemDocument))]
|
||||||
|
internal sealed partial class SpelunkAccountsJsonContext : JsonSerializerContext;
|
||||||
@@ -11,17 +11,17 @@ namespace DodoSSH.Client.Session;
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
|
/// 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
|
/// <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
|
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: who
|
||||||
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
|
/// connects is whoever this device signed in to that Spelunk as, and a link that could supply a credential
|
||||||
/// supply somebody else's.
|
/// would be a link that could supply somebody else's.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
|
/// <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,
|
/// 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
|
/// 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
|
/// Spelunk this device is already signed in to, or open the editor on a new one that the user then has to
|
||||||
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
|
/// sign in for and save. It never sends a sign-in anywhere the user did not sign in to themselves. See
|
||||||
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
/// <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ public interface ISpelunkTunnel
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
/// <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="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="token">This device's sign-in to that Spelunk, which authorises the ticket and is sent nowhere else.</param>
|
||||||
/// <param name="port">
|
/// <param name="port">
|
||||||
/// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the
|
/// 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.
|
/// agent starts an SSH server of its own and Spelunk ignores it.
|
||||||
@@ -83,14 +83,14 @@ public interface ISpelunkTunnel
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
/// 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
|
/// 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
|
/// use it and never ahead of one. The token 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.
|
/// because a record prints its fields, and a token has no business in anything that might be logged.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
|
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
|
||||||
Task<SpelunkTunnelRoute> OpenAsync(
|
Task<SpelunkTunnelRoute> OpenAsync(
|
||||||
string apiBase,
|
string apiBase,
|
||||||
Guid serverId,
|
Guid serverId,
|
||||||
string apiKey,
|
string token,
|
||||||
int port,
|
int port,
|
||||||
SpelunkSignIn? signIn,
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
@@ -273,10 +273,12 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody
|
/// 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.
|
/// watching "Connecting…" should hear that Spelunk is not answering while they still care. Redirects are
|
||||||
|
/// not followed: the sign-in's token goes to the Spelunk it came from, and a redirect is a request to send
|
||||||
|
/// the ticket request somewhere else.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static readonly HttpClient SharedHttp = new(
|
private static readonly HttpClient SharedHttp = new(
|
||||||
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5), AllowAutoRedirect = false })
|
||||||
{
|
{
|
||||||
Timeout = TimeSpan.FromSeconds(30),
|
Timeout = TimeSpan.FromSeconds(30),
|
||||||
};
|
};
|
||||||
@@ -303,7 +305,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||||
string apiBase,
|
string apiBase,
|
||||||
Guid serverId,
|
Guid serverId,
|
||||||
string apiKey,
|
string token,
|
||||||
int port,
|
int port,
|
||||||
SpelunkSignIn? signIn,
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -313,7 +315,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
throw new SpelunkTunnelException(reason);
|
throw new SpelunkTunnelException(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken)
|
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(token), port, signIn, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var socket = await connect(
|
var socket = await connect(
|
||||||
@@ -336,32 +338,27 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
|
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Refuses a stored token that could not be a header value.</summary>
|
||||||
/// Trims the stored key and refuses one that could not be a header value.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A key pasted into a credential arrives with a newline behind it often enough to be worth handling, and
|
/// One that would otherwise surface as a <see cref="FormatException"/> from inside <see cref="HttpClient"/>
|
||||||
/// one with a space in the middle would otherwise surface as a <see cref="FormatException"/> from inside
|
/// rather than as a sentence. It came from Spelunk and was checked then, so this is a damaged cache.
|
||||||
/// <see cref="HttpClient"/> rather than as a sentence about the credential.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string Sanitise(string apiKey)
|
private static string Sanitise(string token)
|
||||||
{
|
{
|
||||||
var trimmed = apiKey?.Trim() ?? string.Empty;
|
if (string.IsNullOrEmpty(token) || token.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
|
||||||
|
|
||||||
if (trimmed.Length == 0 || trimmed.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
|
|
||||||
{
|
{
|
||||||
throw new SpelunkTunnelException(
|
throw new SpelunkTunnelException(
|
||||||
"The credential chosen for the Spelunk API key does not hold one. A Spelunk API key is one "
|
"This device's sign-in to Spelunk is damaged. Sign in to Spelunk again.",
|
||||||
+ "unbroken run of characters starting with spelunk_; paste it as the credential's password.");
|
HttpStatusCode.Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
return trimmed;
|
return token;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<SpelunkTicketDocument> IssueTicketAsync(
|
private async Task<SpelunkTicketDocument> IssueTicketAsync(
|
||||||
string apiBase,
|
string apiBase,
|
||||||
Guid serverId,
|
Guid serverId,
|
||||||
string apiKey,
|
string token,
|
||||||
int port,
|
int port,
|
||||||
SpelunkSignIn? signIn,
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -370,7 +367,7 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
HttpMethod.Post,
|
HttpMethod.Post,
|
||||||
new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute));
|
new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute));
|
||||||
|
|
||||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
|
||||||
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
|
// 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.
|
// do not need chunked encoding, and not every proxy in front of an API accepts it.
|
||||||
@@ -495,18 +492,19 @@ public sealed class SpelunkTunnel : ISpelunkTunnel
|
|||||||
? $": {said}"
|
? $": {said}"
|
||||||
: ". Update DodoSSH, or check that the API address is Spelunk's."),
|
: ". Update DodoSSH, or check that the API address is Spelunk's."),
|
||||||
|
|
||||||
|
// Spelunk says why when it knows: signed out there, a changed password, sessions ended.
|
||||||
HttpStatusCode.Unauthorized =>
|
HttpStatusCode.Unauthorized =>
|
||||||
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
|
"Spelunk signed this device out"
|
||||||
+ "Spelunk and paste it into the credential this host uses for it.",
|
+ (detail is null ? ". Sign in to Spelunk again." : $": {detail}"),
|
||||||
|
|
||||||
// The detail is the useful half here: it says which role the key has and which it needs.
|
|
||||||
HttpStatusCode.Forbidden =>
|
HttpStatusCode.Forbidden =>
|
||||||
"The Spelunk API key is not allowed to open a terminal on this server"
|
"You are not allowed to open a terminal on this server"
|
||||||
+ (detail is null ? ". Opening one needs a key with the Admin role." : $": {detail}"),
|
+ (detail is null
|
||||||
|
? ". It is in an organisation you are not a member of, or your role on it is below Admin."
|
||||||
|
: $": {detail}"),
|
||||||
|
|
||||||
HttpStatusCode.NotFound =>
|
HttpStatusCode.NotFound =>
|
||||||
"Spelunk does not know this server in the API key's organisation. It may have been removed, or "
|
"Spelunk does not know this server, or you are not allowed to see it. It may have been removed.",
|
||||||
+ "the key belongs to a different organisation.",
|
|
||||||
|
|
||||||
HttpStatusCode.ServiceUnavailable =>
|
HttpStatusCode.ServiceUnavailable =>
|
||||||
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the "
|
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the "
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ public sealed partial class VaultSession : IAsyncDisposable
|
|||||||
Vault = new VaultStore(caches, clock);
|
Vault = new VaultStore(caches, clock);
|
||||||
Unlock = new UnlockStore(caches, clock);
|
Unlock = new UnlockStore(caches, clock);
|
||||||
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
|
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
|
||||||
|
SpelunkSignIns = new SpelunkSignInStore(caches, protector, clock);
|
||||||
// The two log repositories first, and unaudited: the recorder writes through one of them, so a log
|
// The two log repositories first, and unaudited: the recorder writes through one of them, so a log
|
||||||
// that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
|
// that logged itself would produce an entry per entry without end. IItemKind.IsAudited is what
|
||||||
// actually stops it; building them first is what lets the recorder exist before the kinds that use
|
// actually stops it; building them first is what lets the recorder exist before the kinds that use
|
||||||
@@ -125,7 +126,6 @@ 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>
|
||||||
@@ -201,13 +201,6 @@ 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
|
||||||
@@ -247,6 +240,13 @@ public sealed partial class VaultSession : IAsyncDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal RememberedSignInStore SignIn { get; }
|
internal RememberedSignInStore SignIn { get; }
|
||||||
|
|
||||||
|
/// <summary>The Spelunks this device is signed in to, and what each sign-in gave it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Kept on this device only, sealed like the remembered sign-in above and wiped with the cache on sign-out:
|
||||||
|
/// each device signs in to Spelunk through its own browser. See <c>SpelunkAccounts</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkSignInStore SpelunkSignIns { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
|
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
|
|||||||
/// <summary>The keychain host this was, if it was one.</summary>
|
/// <summary>The keychain host this was, if it was one.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
|
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
|
||||||
/// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. It is what lets the Connections screen offer
|
/// data — see <c>VaultViewModel.ConnectToEndpointAsync</c>. It is what lets the connect prompt offer the
|
||||||
/// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own
|
/// right thing when one of these rows is tapped: a keychain host has its own authentication behind it, and
|
||||||
/// authentication behind it, and an address has only the box it was typed into.
|
/// an address has only the password typed for it — see <c>MainWindowViewModel.ChoosePromptRecentAsync</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal Guid? HostId => entry.Secret.HostId;
|
internal Guid? HostId => entry.Secret.HostId;
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ internal sealed partial class MainWindowViewModel
|
|||||||
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
|
/// 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
|
/// 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
|
/// 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
|
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never starts a sign-in by itself, and only
|
||||||
/// host already sends it to.
|
/// connects through a Spelunk this device is already signed in to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
||||||
|
|||||||
@@ -373,6 +373,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly ISpelunkTunnel? spelunk;
|
private readonly ISpelunkTunnel? spelunk;
|
||||||
|
|
||||||
|
/// <summary>What signs this device in to Spelunk, handed to each vault this shell opens.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Null means the system browser and a loopback listener, which is what the desktop uses; the phone passes
|
||||||
|
/// its own browser and redirect, and a test one that never opens a browser.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly ISpelunkAccounts? spelunkAccounts;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
|
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -594,12 +601,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
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)
|
ISpelunkTunnel? spelunk = null,
|
||||||
|
ISpelunkAccounts? spelunkAccounts = null)
|
||||||
{
|
{
|
||||||
this.reachability = reachability;
|
this.reachability = reachability;
|
||||||
this.spelunk = spelunk;
|
this.spelunk = spelunk;
|
||||||
|
this.spelunkAccounts = spelunkAccounts;
|
||||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||||
|
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
this.caches = caches;
|
this.caches = caches;
|
||||||
this.workspace = workspace;
|
this.workspace = workspace;
|
||||||
@@ -1521,10 +1529,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// to the same value.
|
/// to the same value.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
|
/// <b>Not gated on there being a tab, because the last clause already is.</b> A selected tab with a
|
||||||
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
|
/// session is what this needs, so the surface with nothing open — which the rail's Terminal entry reaches
|
||||||
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
|
/// deliberately — has the WebView collapsed, and that is the rectangle <see cref="IsConnectPromptShowing"/>
|
||||||
/// Avalonia draws and nothing occludes.
|
/// draws its prompt in.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
||||||
@@ -1852,16 +1860,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// <summary>Switches to the terminal surface.</summary>
|
/// <summary>Switches to the terminal surface.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
|
/// The other half of <see cref="ShowScreenCommand"/>: the phone's bottom bar and the desktop rail's
|
||||||
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
|
/// Terminal entry both name the terminal beside the pages, so the destination needs a button, and a
|
||||||
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
|
/// button needs a command.
|
||||||
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
|
|
||||||
/// button, and a button needs a command.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
|
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — both
|
||||||
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
|
/// heads draw the connect prompt there; see <see cref="IsConnectPromptShowing"/> — which is worth being
|
||||||
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
|
/// able to reach deliberately.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1948,57 +1954,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(HasRecentConnections));
|
OnPropertyChanged(nameof(HasRecentConnections));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Goes back to a machine that has been connected to before.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
|
|
||||||
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
|
|
||||||
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
|
|
||||||
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
|
|
||||||
/// path that had to answer all of that again.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
|
|
||||||
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
|
|
||||||
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
|
|
||||||
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
|
|
||||||
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
|
|
||||||
/// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
|
|
||||||
/// is deliberately not restored is the password: it was never stored, which is the whole point of the
|
|
||||||
/// manual path, and a field that filled itself in would be claiming otherwise.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
|
||||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
|
||||||
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
|
|
||||||
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
|
|
||||||
/// out — arriving at the hosts screen with nothing selected and no explanation.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private void ConnectToRecent(ConnectionLogRowViewModel row)
|
|
||||||
{
|
|
||||||
if (row is null || Vault is not { } vault)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.HostId is { } hostId
|
|
||||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
|
||||||
&& vault.IsVaultShown(known.VaultId))
|
|
||||||
{
|
|
||||||
vault.AskAboutHostCommand.Execute(known);
|
|
||||||
ShowScreen(ShellScreen.Hosts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
vault.ManualTarget = row.Address;
|
|
||||||
vault.ManualStatus = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- The rail's own page grouping ----
|
// ---- The rail's own page grouping ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2365,7 +2320,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
internal bool HasTabs => Tabs.Count > 0;
|
internal bool HasTabs => Tabs.Count > 0;
|
||||||
|
|
||||||
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
|
private void RaiseTabState()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(HasTabs));
|
||||||
|
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes one terminal, ending its shell.
|
/// Closes one terminal, ending its shell.
|
||||||
@@ -2436,6 +2395,343 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The empty terminal's prompt ----
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the terminal's rectangle is asking what to connect to: the terminal surface, with nothing open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The desktop's answer to pressing Terminal with no session.</b> The surface used to draw an empty
|
||||||
|
/// frame there — the tab row's "+" was the only thing on it that did anything. A terminal with nothing in
|
||||||
|
/// it should do what a terminal does, which is ask: so the frame holds a prompt that takes a saved host's
|
||||||
|
/// name, or <c>user@host</c> for a machine that is not saved, and asks for a password when one is needed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Exclusive with the renderer by construction: the WebView shows only for a selected tab with a session,
|
||||||
|
/// and this only while there are no tabs at all. That is what lets Avalonia draw it in the WebView's
|
||||||
|
/// rectangle, the way <c>ConnectingCard</c> already is. See <see cref="IsTerminalShowing"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal bool IsConnectPromptShowing => IsTerminalSurface && !HasTabs;
|
||||||
|
|
||||||
|
/// <summary>What was typed at the prompt: a saved host's name, or <c>user@host[:port]</c>.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string promptText = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The password typed at the prompt's second line.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Cleared the moment it is used or abandoned. It is never written into the vault's own password box
|
||||||
|
/// either: that box carries a remember-this tick the prompt never offered — see
|
||||||
|
/// <c>VaultViewModel.ConnectToHostAsync</c>.
|
||||||
|
/// </remarks>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string promptPassword = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether the prompt has moved on to asking for a password.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsPromptAskingForTarget))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowsPromptMatches))]
|
||||||
|
private bool isPromptAskingForPassword;
|
||||||
|
|
||||||
|
/// <summary>Whether the prompt is still asking what to connect to.</summary>
|
||||||
|
internal bool IsPromptAskingForTarget => !IsPromptAskingForPassword;
|
||||||
|
|
||||||
|
/// <summary>The second line's prompt, in <c>ssh</c>'s own words: whose password it is.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string promptPasswordLabel = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Why the prompt refused what was typed, or empty.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasPromptRefusal))]
|
||||||
|
private string promptRefusal = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether there is a refusal to print under the prompt.</summary>
|
||||||
|
internal bool HasPromptRefusal => PromptRefusal.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Saved hosts whose name or address matches what has been typed so far, best first; Tab takes the first.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Empty for a query with an <c>@</c> in it, which is an address rather than a name, and for an empty
|
||||||
|
/// one: a list of every host under a prompt nobody has typed into yet is the hosts screen, drawn worse.
|
||||||
|
/// </remarks>
|
||||||
|
internal ObservableCollection<HostRowViewModel> PromptMatches { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether there are matches to print under the prompt.</summary>
|
||||||
|
internal bool HasPromptMatches => PromptMatches.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Whether the matches are drawn: only while the name is being asked for, not under its password.</summary>
|
||||||
|
internal bool ShowsPromptMatches => HasPromptMatches && !IsPromptAskingForPassword;
|
||||||
|
|
||||||
|
/// <summary>The saved host the password line is for, when it is for one.</summary>
|
||||||
|
private HostRowViewModel? promptHost;
|
||||||
|
|
||||||
|
/// <summary>The typed address the password line is for, when it is for one.</summary>
|
||||||
|
private VaultViewModel.ManualEndpoint? promptEndpoint;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Enter at the prompt: takes what was typed, or — on the password line — connects.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A name is matched exactly, ignoring case, because Enter on a name is a decision and not a search: the
|
||||||
|
/// matches printed under the prompt are how a partial name becomes a whole one, by Tab or by a click.
|
||||||
|
/// Two hosts with the same name in two vaults are refused rather than guessed between, and pointed at
|
||||||
|
/// the palette, which shows which vault each came from.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Concurrent executions allowed, for the reason <see cref="ConnectToAdHocTargetAsync"/> gives.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
|
private async Task SubmitPromptAsync()
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsPromptAskingForPassword)
|
||||||
|
{
|
||||||
|
await ConnectFromPromptAsync(vault).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var typed = PromptText.Trim();
|
||||||
|
|
||||||
|
if (typed.Length == 0)
|
||||||
|
{
|
||||||
|
PromptRefusal = "Type the name of a saved host, or user@host for a machine that is not saved.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typed.Contains('@', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (!VaultViewModel.TryParseManualTarget(typed, out var endpoint, out var reason))
|
||||||
|
{
|
||||||
|
PromptRefusal = reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AskForPromptPassword(endpoint);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var named = vault.Hosts
|
||||||
|
.Where(host => string.Equals(host.Label, typed, StringComparison.OrdinalIgnoreCase))
|
||||||
|
.Take(2)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
switch (named.Count)
|
||||||
|
{
|
||||||
|
case 0:
|
||||||
|
PromptRefusal = PromptMatches.Count > 0
|
||||||
|
? $"No saved host is called \"{typed}\". Press Tab for {PromptMatches[0].Label}, or type user@host."
|
||||||
|
: $"No saved host is called \"{typed}\". Type user@host to reach a machine that is not saved.";
|
||||||
|
return;
|
||||||
|
|
||||||
|
case > 1:
|
||||||
|
PromptRefusal =
|
||||||
|
$"More than one saved host is called \"{typed}\". Press Ctrl+K to choose between them.";
|
||||||
|
return;
|
||||||
|
|
||||||
|
default:
|
||||||
|
await TakePromptHostAsync(vault, named[0]).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A click on one of the matches printed under the prompt.</summary>
|
||||||
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
|
private async Task ChoosePromptMatchAsync(HostRowViewModel? host)
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault || host is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PromptText = host.Label;
|
||||||
|
await TakePromptHostAsync(vault, host).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A click on one of the recent connections printed under the prompt.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Two answers, because a recent row is one of two different things.</b> A row naming a host still in
|
||||||
|
/// the keychain goes to that host, with whatever the keychain knows about it — its key, its saved
|
||||||
|
/// password, its refusals — exactly as typing its name would. One that names none was typed in as an
|
||||||
|
/// address, and the log stored exactly what was dialled, <c>user@host:port</c>, which is the grammar the
|
||||||
|
/// prompt takes. So it goes back onto the prompt and asks for its password the way a typed one does; the
|
||||||
|
/// password was never stored, which is the whole point of that path.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||||
|
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||||
|
/// user has switched off, and for the same reason rather than by accident: taking it would reach a host
|
||||||
|
/// the rest of the application is deliberately not showing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
|
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault || row is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row.HostId is { } hostId
|
||||||
|
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
||||||
|
&& vault.IsVaultShown(known.VaultId))
|
||||||
|
{
|
||||||
|
PromptText = known.Label;
|
||||||
|
await TakePromptHostAsync(vault, known).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PromptText = row.Address;
|
||||||
|
|
||||||
|
if (VaultViewModel.TryParseManualTarget(row.Address, out var endpoint, out var reason))
|
||||||
|
{
|
||||||
|
AskForPromptPassword(endpoint);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
PromptRefusal = reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Escape at the prompt: off the password line back to the name, or clears the name.
|
||||||
|
/// </summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private void CancelPrompt()
|
||||||
|
{
|
||||||
|
if (IsPromptAskingForPassword)
|
||||||
|
{
|
||||||
|
IsPromptAskingForPassword = false;
|
||||||
|
PromptPassword = string.Empty;
|
||||||
|
PromptRefusal = string.Empty;
|
||||||
|
promptHost = null;
|
||||||
|
promptEndpoint = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
PromptText = string.Empty;
|
||||||
|
PromptRefusal = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Tab at the prompt: completes to the best match, if there is one.</summary>
|
||||||
|
/// <returns>Whether anything was completed, so the key can move the focus as usual when it was not.</returns>
|
||||||
|
internal bool CompletePrompt()
|
||||||
|
{
|
||||||
|
if (IsPromptAskingForPassword || PromptMatches.Count == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PromptText = PromptMatches[0].Label;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Connects to a saved host, or asks for its password first if it needs one.</summary>
|
||||||
|
private async Task TakePromptHostAsync(VaultViewModel vault, HostRowViewModel host)
|
||||||
|
{
|
||||||
|
if (VaultViewModel.AsksForAPassword(host))
|
||||||
|
{
|
||||||
|
promptHost = host;
|
||||||
|
promptEndpoint = null;
|
||||||
|
PromptPasswordLabel = $"{host.Address}'s password:";
|
||||||
|
PromptPassword = string.Empty;
|
||||||
|
PromptRefusal = string.Empty;
|
||||||
|
IsPromptAskingForPassword = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ResetPrompt();
|
||||||
|
|
||||||
|
// No password to offer and nothing to remember: the binding supplies the credential. Any refusal —
|
||||||
|
// a key deleted on another machine, a host with no username — lands on the status line, as it does
|
||||||
|
// for the palette.
|
||||||
|
await vault.ConnectToHostAsync(host, string.Empty, mayRememberPassword: false, CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Moves the prompt on to the password for a typed address.</summary>
|
||||||
|
private void AskForPromptPassword(VaultViewModel.ManualEndpoint endpoint)
|
||||||
|
{
|
||||||
|
promptHost = null;
|
||||||
|
promptEndpoint = endpoint;
|
||||||
|
PromptPasswordLabel = $"{endpoint.Address}'s password:";
|
||||||
|
PromptPassword = string.Empty;
|
||||||
|
PromptRefusal = string.Empty;
|
||||||
|
IsPromptAskingForPassword = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The password line's Enter: dials whichever of the two the line is for.</summary>
|
||||||
|
private async Task ConnectFromPromptAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
if (PromptPassword.Length == 0)
|
||||||
|
{
|
||||||
|
PromptRefusal = "Type the password, or press Esc to go back.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var password = PromptPassword;
|
||||||
|
var host = promptHost;
|
||||||
|
var endpoint = promptEndpoint;
|
||||||
|
|
||||||
|
// Before the dial, so the password does not wait in the box for the length of a handshake. The tab the
|
||||||
|
// connection opens replaces the prompt anyway; this is what is there the next time nothing is open.
|
||||||
|
ResetPrompt();
|
||||||
|
|
||||||
|
if (host is not null)
|
||||||
|
{
|
||||||
|
await vault.ConnectToHostAsync(host, password, mayRememberPassword: false, CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
else if (endpoint is not null)
|
||||||
|
{
|
||||||
|
await vault.ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts the prompt back to an empty first line.</summary>
|
||||||
|
private void ResetPrompt()
|
||||||
|
{
|
||||||
|
IsPromptAskingForPassword = false;
|
||||||
|
PromptPassword = string.Empty;
|
||||||
|
PromptText = string.Empty;
|
||||||
|
PromptRefusal = string.Empty;
|
||||||
|
promptHost = null;
|
||||||
|
promptEndpoint = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A refusal is about what was typed, so typing again clears it; the matches follow every keystroke.
|
||||||
|
/// </remarks>
|
||||||
|
partial void OnPromptTextChanged(string value)
|
||||||
|
{
|
||||||
|
PromptRefusal = string.Empty;
|
||||||
|
|
||||||
|
PromptMatches.Clear();
|
||||||
|
|
||||||
|
var query = value.Trim();
|
||||||
|
|
||||||
|
if (Vault is { } vault && query.Length > 0 && !query.Contains('@', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
foreach (var host in HostsMatching(vault, query).Take(5))
|
||||||
|
{
|
||||||
|
PromptMatches.Add(host);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasPromptMatches));
|
||||||
|
OnPropertyChanged(nameof(ShowsPromptMatches));
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Quick connect ----
|
// ---- Quick connect ----
|
||||||
|
|
||||||
/// <summary>Whether the quick-connect palette is open over the window.</summary>
|
/// <summary>Whether the quick-connect palette is open over the window.</summary>
|
||||||
@@ -2478,13 +2774,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
|
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
|
||||||
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
|
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
|
||||||
/// saving it first — the phone has had a manual box on its terminal screen all along. The query already
|
/// saving it first. The query already is a box, and <c>user@host</c> is the grammar people type into
|
||||||
/// is a box, and <c>user@host</c> is the grammar people type into one, so the palette reads it rather
|
/// one, so the palette reads it rather than growing a second field.
|
||||||
/// than growing a second field.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the phone's box's own parser, so
|
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the connect prompt's parser and
|
||||||
/// the two heads accept exactly the same addresses and refuse the rest with the same sentences.
|
/// the phone's hosts search's too, so every box accepts exactly the same addresses and refuses the rest
|
||||||
|
/// with the same sentences.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
|
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
|
||||||
@@ -2555,7 +2851,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
|
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box
|
/// Cleared whenever the palette closes or goes back to the query, on the same terms the connect prompt
|
||||||
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
|
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
|
||||||
/// last one's password already in the box.
|
/// last one's password already in the box.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -2672,8 +2968,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the phone's manual box's own
|
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the connect prompt's own path,
|
||||||
/// path, so a machine reached from here gets the same tab, the same host-key question and the same
|
/// so a machine reached from here gets the same tab, the same host-key question and the same
|
||||||
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
|
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
|
||||||
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
|
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
|
||||||
/// exactly the one whose key nobody has seen before.
|
/// exactly the one whose key nobody has seen before.
|
||||||
@@ -2827,16 +3123,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
var query = SearchText.Trim();
|
var query = SearchText.Trim();
|
||||||
|
|
||||||
var matches = query.Length == 0
|
foreach (var host in HostsMatching(vault, query).Take(8))
|
||||||
? vault.Hosts.AsEnumerable()
|
|
||||||
: vault.Hosts
|
|
||||||
.Select(host => (host, rank: Rank(host, query)))
|
|
||||||
.Where(candidate => candidate.rank < int.MaxValue)
|
|
||||||
.OrderBy(candidate => candidate.rank)
|
|
||||||
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
|
||||||
.Select(candidate => candidate.host);
|
|
||||||
|
|
||||||
foreach (var host in matches.Take(8))
|
|
||||||
{
|
{
|
||||||
SearchResults.Add(host);
|
SearchResults.Add(host);
|
||||||
}
|
}
|
||||||
@@ -2866,6 +3153,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(HasNothingToOffer));
|
OnPropertyChanged(nameof(HasNothingToOffer));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The vault's hosts that match what was typed, best first; every host for an empty query.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Shared by the palette and the empty terminal's prompt, so a name typed into either finds the same
|
||||||
|
/// machines in the same order.
|
||||||
|
/// </remarks>
|
||||||
|
private static IEnumerable<HostRowViewModel> HostsMatching(VaultViewModel vault, string query) =>
|
||||||
|
query.Length == 0
|
||||||
|
? vault.Hosts
|
||||||
|
: vault.Hosts
|
||||||
|
.Select(host => (host, rank: Rank(host, query)))
|
||||||
|
.Where(candidate => candidate.rank < int.MaxValue)
|
||||||
|
.OrderBy(candidate => candidate.rank)
|
||||||
|
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
|
||||||
|
.Select(candidate => candidate.host);
|
||||||
|
|
||||||
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
/// <summary>Where one host places against what was typed, or <see cref="int.MaxValue"/> for no match.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -3326,7 +3628,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
connectionLog,
|
connectionLog,
|
||||||
visibility,
|
visibility,
|
||||||
reachability,
|
reachability,
|
||||||
spelunk);
|
spelunk,
|
||||||
|
spelunkAccounts,
|
||||||
|
OnAccountRefusedAsync);
|
||||||
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
|
||||||
@@ -3540,16 +3844,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
catch (OidcException exception) when (IsRefusedGrant(exception))
|
catch (OidcException exception) when (IsRefusedGrant(exception))
|
||||||
{
|
{
|
||||||
// The provider answered and said no: the session was revoked, or the token was rotated and
|
await OnResumeRefusedAsync(vault, exception, cancellationToken).ConfigureAwait(true);
|
||||||
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get
|
|
||||||
// the same answer, so the token goes and the user is told the one thing that fixes it.
|
|
||||||
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
|
||||||
|
|
||||||
var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
|
||||||
+ "Sign in again from Preferences to start syncing.";
|
|
||||||
|
|
||||||
vault.OfflineReason = message;
|
|
||||||
Announce(message);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -3568,6 +3863,45 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The provider answered a resume and said no.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The session was revoked, or the token was rotated and this machine kept the old one. Retrying costs a
|
||||||
|
/// round trip a minute and can only ever get the same answer, so the token goes and the user is told the one
|
||||||
|
/// thing that fixes it.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task OnResumeRefusedAsync(VaultViewModel vault, OidcException exception, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// And with it every Spelunk this device is signed in to, as when the vault finds the account refused
|
||||||
|
// before a Spelunk connection: a sign-in there is only ever used on behalf of a DodoSSH account that
|
||||||
|
// still stands, and this one, for now, does not.
|
||||||
|
var spelunks = await SignOutOfEverySpelunkQuietlyAsync(vault).ConfigureAwait(true);
|
||||||
|
|
||||||
|
var message = $"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||||
|
+ (spelunks ? "This device has been signed out of Spelunk too. " : string.Empty)
|
||||||
|
+ "Sign in again from Preferences to start syncing.";
|
||||||
|
|
||||||
|
vault.OfflineReason = message;
|
||||||
|
Announce(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signs out of every Spelunk, for a DodoSSH sign-in the provider refused; whether there were any.</summary>
|
||||||
|
/// <remarks>Never throws: it runs where the refusal is already being reported, and a cache that will not take it is wiped on sign-out anyway.</remarks>
|
||||||
|
private static async Task<bool> SignOutOfEverySpelunkQuietlyAsync(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var any = vault.HasSpelunkSignIns;
|
||||||
|
await vault.SignOutOfEverySpelunkAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
return any;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether a failed resume was the provider refusing the token, rather than failing to answer.
|
/// Whether a failed resume was the provider refusing the token, rather than failing to answer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3609,6 +3943,33 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The identity provider refused this account when the vault checked it — before a Spelunk connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The same answer a refused resume gets, from the other side: the sign-in is forgotten and the machine goes
|
||||||
|
/// offline, saying why. The vault has already ended every Spelunk sign-in on this device by then.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task OnAccountRefusedAsync(string reason, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
connection?.Dispose();
|
||||||
|
connection = null;
|
||||||
|
|
||||||
|
var message = $"Your DodoSSH sign-in was refused, so this machine is offline and signed out of Spelunk: "
|
||||||
|
+ $"{reason} Sign in again from Preferences.";
|
||||||
|
|
||||||
|
if (Vault is { } vault)
|
||||||
|
{
|
||||||
|
vault.OfflineReason = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Announce(message);
|
||||||
|
OnPropertyChanged(nameof(IsOnline));
|
||||||
|
RaiseSyncState();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
||||||
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
|
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -3904,6 +4265,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
if (Vault is { } vault)
|
if (Vault is { } vault)
|
||||||
{
|
{
|
||||||
|
// Spelunk first, while the sign-ins can still be read: each one ends there too, not only here.
|
||||||
|
await vault.SignOutOfEverySpelunkAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
await vault.Session
|
await vault.Session
|
||||||
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
|
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
@@ -5906,6 +6270,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||||
OnPropertyChanged(nameof(IsConnectingShowing));
|
OnPropertyChanged(nameof(IsConnectingShowing));
|
||||||
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
|
||||||
|
OnPropertyChanged(nameof(IsConnectPromptShowing));
|
||||||
|
|
||||||
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
|
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
|
||||||
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
|
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
this.restart = restart;
|
this.restart = restart;
|
||||||
|
|
||||||
CurrentVersion = updates.CurrentVersion;
|
CurrentVersion = updates.CurrentVersion;
|
||||||
|
UnsupportedReason = (updates as UnavailableUpdateChannel)?.Reason;
|
||||||
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
||||||
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,16 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
/// <summary>What this build calls itself.</summary>
|
/// <summary>What this build calls itself.</summary>
|
||||||
internal string CurrentVersion { get; }
|
internal string CurrentVersion { get; }
|
||||||
|
|
||||||
|
/// <summary>Why this copy cannot replace itself, when the channel had something particular to say.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Null in the ordinary cases, where the preferences screen's own sentence about checkouts and unzipped
|
||||||
|
/// copies is the right one. See <see cref="UnavailableUpdateChannel"/> for the case where it is not.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? UnsupportedReason { get; }
|
||||||
|
|
||||||
|
/// <summary>Unsupported, with nothing more particular to say than the screen's own sentence.</summary>
|
||||||
|
internal bool IsUnsupportedForTheUsualReason => IsUnsupported && UnsupportedReason is null;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private UpdateState state;
|
private UpdateState state;
|
||||||
|
|
||||||
@@ -248,6 +259,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsSupported));
|
OnPropertyChanged(nameof(IsSupported));
|
||||||
OnPropertyChanged(nameof(IsUnsupported));
|
OnPropertyChanged(nameof(IsUnsupported));
|
||||||
|
OnPropertyChanged(nameof(IsUnsupportedForTheUsualReason));
|
||||||
OnPropertyChanged(nameof(IsChecking));
|
OnPropertyChanged(nameof(IsChecking));
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
OnPropertyChanged(nameof(IsReady));
|
OnPropertyChanged(nameof(IsReady));
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DodoSSH.Client.Domain;
|
using DodoSSH.Client.Domain;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
@@ -62,18 +61,6 @@ internal sealed partial class VaultViewModel
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string editorSpelunkServerId = string.Empty;
|
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>
|
/// <summary>
|
||||||
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
|
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -119,7 +106,7 @@ internal sealed partial class VaultViewModel
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The request a route is used for is made with <see cref="Through"/>, which is where a Spelunk sign-in's
|
/// 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.
|
/// throwaway key and account replace the ones the host resolved to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||||
@@ -170,13 +157,13 @@ internal sealed partial class VaultViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens a Spelunk tunnel to a host, with the key it names.
|
/// Opens a Spelunk tunnel to a host, as whoever this device is signed in to that Spelunk as.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
|
/// DodoSSH's own account is checked first and the sign-in read at the moment of connecting — see
|
||||||
/// path uses, and goes only to the API address the host names — and for a saved Spelunk key, only when
|
/// <see cref="SpelunkSignInForAsync"/>. The sign-in only ever goes to the Spelunk it came from, since it is
|
||||||
/// that is the address it was saved for. See <see cref="SpelunkApiKeyFor"/>.
|
/// found by that address. One Spelunk refuses (a 401) is forgotten, so the next attempt asks to sign in.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
||||||
@@ -194,17 +181,27 @@ internal sealed partial class VaultViewModel
|
|||||||
SpelunkRoute route,
|
SpelunkRoute route,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var apiKey = SpelunkApiKeyFor(row, route);
|
var signIn = await SpelunkSignInForAsync(row, route, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
return await Spelunk
|
try
|
||||||
.OpenAsync(
|
{
|
||||||
route.ApiBase,
|
// The sign-in's own address rather than the route's, though they are one string: the token goes to
|
||||||
route.ServerId,
|
// the Spelunk that issued it, and that is said here rather than left to the lookup that found it.
|
||||||
apiKey,
|
return await Spelunk
|
||||||
row.Resolved.Port.Value,
|
.OpenAsync(
|
||||||
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
signIn.ApiBase,
|
||||||
cancellationToken)
|
route.ServerId,
|
||||||
.ConfigureAwait(true);
|
signIn.Token,
|
||||||
|
row.Resolved.Port.Value,
|
||||||
|
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (SpelunkTunnelException refusal)
|
||||||
|
{
|
||||||
|
await ForgetRefusedSpelunkSignInAsync(route.ApiBase, refusal).ConfigureAwait(true);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -220,19 +217,17 @@ internal sealed partial class VaultViewModel
|
|||||||
/// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's.
|
/// 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>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
|
/// <b>Otherwise, when this device is signed in to that Spelunk, the host is made and connected to.</b> In
|
||||||
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
|
/// 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
|
/// SSH key or password. It is saved the ordinary way and syncs like any host. Nothing leaks by this: the
|
||||||
/// credential a saved host already sends there. It is saved the ordinary way and syncs like any host.
|
/// sign-in goes back to the Spelunk the user signed in to, and the server is one they may reach or Spelunk
|
||||||
/// Nothing leaks by this: the key goes where the user already said it goes, and the server is one that
|
/// refuses it.
|
||||||
/// key's own organisation holds or Spelunk refuses it.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The first link from a Spelunk with no key opens the editor instead, and nothing more.</b> Anything
|
/// <b>A link from a Spelunk this device is not signed in to opens the editor instead, and nothing
|
||||||
/// on this machine can make a link, so it must never be able to send an API key to an address the user
|
/// more.</b> Anything on this machine can make a link, so it must never be able to start a sign-in by
|
||||||
/// has not sent it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
|
/// itself. The host is pre-filled — signing in through Spelunk, tagged, in the personal vault — and the
|
||||||
/// vault — and the user chooses the key and saves; every later link from that Spelunk connects straight
|
/// editor offers to sign in; every later link from that Spelunk connects straight away.
|
||||||
/// away.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||||
@@ -251,15 +246,15 @@ internal sealed partial class VaultViewModel
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
|
var signedIn = await session.SpelunkSignIns.FindAsync(link.ApiBase, CancellationToken.None).ConfigureAwait(true) is not null;
|
||||||
|
|
||||||
if (personal is { } into && knownKey is { } key)
|
if (personal is { } into && signedIn)
|
||||||
{
|
{
|
||||||
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
|
await AddFromLinkAndConnectAsync(link, into).ConfigureAwait(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
|
await OpenLinkInEditorAsync(link, personal).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The personal vault, when this session can read and write it.</summary>
|
/// <summary>The personal vault, when this session can read and write it.</summary>
|
||||||
@@ -273,33 +268,8 @@ internal sealed partial class VaultViewModel
|
|||||||
private static bool IsSpelunkTag(TagSecret tag) =>
|
private static bool IsSpelunkTag(TagSecret tag) =>
|
||||||
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
|
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>The host a link makes: what the link says, signing in through Spelunk.</summary>
|
||||||
/// The key the user has already agreed may go to this Spelunk, or null when there is none.
|
private static HostSecret HostFromLink(SpelunkLink link, TagSet tags) => new()
|
||||||
/// </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,
|
Label = link.Name,
|
||||||
Hostname = link.Hostname,
|
Hostname = link.Hostname,
|
||||||
@@ -309,13 +279,12 @@ internal sealed partial class VaultViewModel
|
|||||||
{
|
{
|
||||||
ApiBase = link.ApiBase,
|
ApiBase = link.ApiBase,
|
||||||
ServerId = link.ServerId,
|
ServerId = link.ServerId,
|
||||||
ApiKeyCredentialId = apiKeyCredentialId,
|
|
||||||
SignInThroughSpelunk = true,
|
SignInThroughSpelunk = true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
|
/// <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)
|
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId)
|
||||||
{
|
{
|
||||||
if (IsBusy)
|
if (IsBusy)
|
||||||
{
|
{
|
||||||
@@ -335,7 +304,7 @@ internal sealed partial class VaultViewModel
|
|||||||
hostId = await session.Hosts
|
hostId = await session.Hosts
|
||||||
.CreateAsync(
|
.CreateAsync(
|
||||||
vaultId,
|
vaultId,
|
||||||
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
|
HostFromLink(link, TagSet.Create([tagId])),
|
||||||
CancellationToken.None)
|
CancellationToken.None)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -358,14 +327,14 @@ internal sealed partial class VaultViewModel
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
|
/// Opens the editor on a new host from a link, for a link from a Spelunk this device is not signed in to.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is
|
/// 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
|
/// 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.
|
/// cancelled stays, as the editor's own new-tag box leaves it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey)
|
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal)
|
||||||
{
|
{
|
||||||
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
|
// 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.
|
// is not worth somebody's unsaved host.
|
||||||
@@ -390,14 +359,12 @@ internal sealed partial class VaultViewModel
|
|||||||
EditorSpelunkApi = link.ApiBase;
|
EditorSpelunkApi = link.ApiBase;
|
||||||
EditorSpelunkServerId = link.ServerId.ToString();
|
EditorSpelunkServerId = link.ServerId.ToString();
|
||||||
|
|
||||||
BuildSpelunkKeyChoices(knownKey);
|
|
||||||
|
|
||||||
IsHostPaneOpen = true;
|
IsHostPaneOpen = true;
|
||||||
|
|
||||||
var said = EditorSelectedSpelunkKey is null
|
var said = EditorSpelunkNeedsSignIn
|
||||||
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose your Spelunk API "
|
? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Sign in to that Spelunk from the editor and "
|
||||||
+ "key and save — Spelunk signs you in, so no SSH key is needed, and later links from this "
|
+ "save — Spelunk signs you in to the server, so no SSH key is needed, and later links from this "
|
||||||
+ "Spelunk connect straight away. Saving the key under Settings → Security does the same."
|
+ "Spelunk connect straight away."
|
||||||
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
|
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
|
||||||
|
|
||||||
Status = said;
|
Status = said;
|
||||||
@@ -460,22 +427,16 @@ internal sealed partial class VaultViewModel
|
|||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Whether a host needs this key or credential to connect, as its resolved binding.</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>
|
/// <remarks>
|
||||||
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
|
/// 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.
|
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
||||||
(!row.Host.SignsInThroughSpelunk
|
!row.Host.SignsInThroughSpelunk
|
||||||
&& row.Resolved.Binding is { } binding
|
&& row.Resolved.Binding is { } binding
|
||||||
&& binding.Kind == kind
|
&& binding.Kind == kind
|
||||||
&& binding.EntityId == entityId)
|
&& 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>
|
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
||||||
private void LoadEditorSpelunk(SpelunkRoute? route)
|
private void LoadEditorSpelunk(SpelunkRoute? route)
|
||||||
@@ -484,36 +445,9 @@ internal sealed partial class VaultViewModel
|
|||||||
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
|
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
|
||||||
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
||||||
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
||||||
|
EditorSpelunkSignInNote = string.Empty;
|
||||||
|
|
||||||
BuildSpelunkKeyChoices(route?.ApiKeyCredentialId);
|
UpdateEditorSpelunkSignIn();
|
||||||
}
|
|
||||||
|
|
||||||
/// <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>
|
/// <summary>The editor's route, or null when the box is not ticked.</summary>
|
||||||
@@ -536,7 +470,6 @@ internal sealed partial class VaultViewModel
|
|||||||
? normalized
|
? normalized
|
||||||
: EditorSpelunkApi.Trim(),
|
: EditorSpelunkApi.Trim(),
|
||||||
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
||||||
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
|
|
||||||
SignInThroughSpelunk = EditorSpelunkSignIn,
|
SignInThroughSpelunk = EditorSpelunkSignIn,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
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.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Net;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using DodoSSH.Client.Auth;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>One Spelunk this device is signed in to, as a row on the Security page.</summary>
|
||||||
|
/// <remarks>Carries the token for the sign-out that needs it, and shows nothing of it: the XAML binds the address and who.</remarks>
|
||||||
|
internal sealed class SpelunkSignInRowViewModel(SpelunkSignInRecord record, int hosts)
|
||||||
|
{
|
||||||
|
internal SpelunkSignInRecord Record => record;
|
||||||
|
|
||||||
|
internal string ApiBase => record.ApiBase;
|
||||||
|
|
||||||
|
/// <summary>Who this device is signed in as there.</summary>
|
||||||
|
internal string Who => string.Equals(record.DisplayName, record.Email, StringComparison.Ordinal)
|
||||||
|
? record.Email
|
||||||
|
: $"{record.DisplayName} · {record.Email}";
|
||||||
|
|
||||||
|
/// <summary>How many hosts reach Spelunk through it, in words.</summary>
|
||||||
|
internal string UsedBy { get; } = hosts switch
|
||||||
|
{
|
||||||
|
0 => "No host uses it yet",
|
||||||
|
1 => "1 host uses it",
|
||||||
|
_ => $"{hosts} hosts use it",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signing this device in to Spelunk, and what a sign-in lets a host and a link do.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Per device, through the browser.</b> Each device signs in to a Spelunk once — Settings → Security, or the
|
||||||
|
/// button in a Spelunk host's editor — and Spelunk asks its person, on its own page, whether to let this
|
||||||
|
/// device in. What comes back is kept on this device only (<see cref="VaultSession.SpelunkSignIns"/>) and
|
||||||
|
/// opens terminals on that Spelunk's servers in every organisation its person is in, as them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It ends with the person.</b> Spelunk refuses it once they are removed, sign it out, or change their
|
||||||
|
/// password — a 401, after which it is forgotten here and the user is asked to sign in again. And DodoSSH
|
||||||
|
/// checks its own account before every Spelunk connection (<see cref="VerifyDodoSshSignInAsync"/>), so an
|
||||||
|
/// account removed from DodoSSH stops reaching Spelunk too.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class VaultViewModel
|
||||||
|
{
|
||||||
|
/// <summary>What signs in to Spelunk when no caller supplied something else: the system browser and a loopback listener.</summary>
|
||||||
|
private static readonly ISpelunkAccounts DefaultSpelunkAccounts = new SpelunkAccounts(new SystemBrowserLauncher());
|
||||||
|
|
||||||
|
/// <summary>The Spelunks this device is signed in to.</summary>
|
||||||
|
internal ObservableCollection<SpelunkSignInRowViewModel> SpelunkSignIns { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>Whether there is any sign-in to list.</summary>
|
||||||
|
internal bool HasSpelunkSignIns => SpelunkSignIns.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>The Spelunk API address in the Security page's form, as typed.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string spelunkSignInFormApi = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>What the Security page's form has to say about the last sign-in, 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]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasSpelunkSignInFormNote))]
|
||||||
|
private string spelunkSignInFormNote = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether a sign-in is waiting for the browser.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool isSigningInToSpelunk;
|
||||||
|
|
||||||
|
/// <summary>Who the editor's Spelunk would be reached as from this device, or why it would not be.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private string editorSpelunkSignInNote = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Whether this device still has to sign in to the Spelunk the editor names.</summary>
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool editorSpelunkNeedsSignIn;
|
||||||
|
|
||||||
|
internal bool HasSpelunkSignInFormNote => SpelunkSignInFormNote.Length > 0;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="DefaultSpelunkAccounts" />
|
||||||
|
private ISpelunkAccounts SpelunkAccounts => spelunkAccounts ?? DefaultSpelunkAccounts;
|
||||||
|
|
||||||
|
partial void OnEditorSpelunkApiChanged(string value) => UpdateEditorSpelunkSignIn();
|
||||||
|
|
||||||
|
/// <summary>Signs this device in to the Spelunk in the Security page's form.</summary>
|
||||||
|
[RelayCommand(IncludeCancelCommand = true)]
|
||||||
|
private async Task SignInToSpelunkAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SpelunkSignInFormNote = string.Empty;
|
||||||
|
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(SpelunkSignInFormApi, out var apiBase, out var reason))
|
||||||
|
{
|
||||||
|
SpelunkSignInFormNote = reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SpelunkSignInFormApi = apiBase;
|
||||||
|
SpelunkSignInFormNote = await SignInToSpelunkCoreAsync(apiBase, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signs this device in to the Spelunk the host editor names.</summary>
|
||||||
|
[RelayCommand(IncludeCancelCommand = true)]
|
||||||
|
private async Task SignInToEditorSpelunkAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var apiBase, out var reason))
|
||||||
|
{
|
||||||
|
EditorSpelunkSignInNote = reason;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSpelunkSignInNote = await SignInToSpelunkCoreAsync(apiBase, cancellationToken).ConfigureAwait(true);
|
||||||
|
UpdateEditorSpelunkSignIn(keepNote: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Signs this device out of one Spelunk: there, when it can be reached, and here regardless.</summary>
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task SignOutOfSpelunkAsync(SpelunkSignInRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await SpelunkAccounts.SignOutAsync(row.Record, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
await session.SpelunkSignIns.ForgetAsync(row.ApiBase, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
await ReloadSpelunkSignInsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = $"Signed out of Spelunk at {row.ApiBase}.";
|
||||||
|
SpelunkSignInFormNote = Status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signs out of every Spelunk this device is signed in to — on signing out of DodoSSH, and when DodoSSH
|
||||||
|
/// finds its own account refused. Best effort there, certain here.
|
||||||
|
/// </summary>
|
||||||
|
internal async Task SignOutOfEverySpelunkAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
foreach (var signIn in await session.SpelunkSignIns.ListAsync(cancellationToken).ConfigureAwait(true))
|
||||||
|
{
|
||||||
|
await SpelunkAccounts.SignOutAsync(signIn, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.SpelunkSignIns.ForgetAllAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
await ReloadSpelunkSignInsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The whole of a sign-in, from either place it starts; returns what to say about it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>DodoSSH has to be online and signed in to start one</b>, for the reason it has to be to use one:
|
||||||
|
/// Spelunk is reached as somebody this DodoSSH account vouches for on this device.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<string> SignInToSpelunkCoreAsync(string apiBase, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (IsSigningInToSpelunk)
|
||||||
|
{
|
||||||
|
return "A sign-in to Spelunk is already waiting for the browser.";
|
||||||
|
}
|
||||||
|
|
||||||
|
IsSigningInToSpelunk = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await VerifyDodoSshSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = $"Finish signing in to Spelunk at {apiBase} in your browser…";
|
||||||
|
|
||||||
|
var signIn = await SpelunkAccounts.SignInAsync(apiBase, cancellationToken).ConfigureAwait(true);
|
||||||
|
await session.SpelunkSignIns.SaveAsync(signIn, cancellationToken).ConfigureAwait(true);
|
||||||
|
await ReloadSpelunkSignInsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
Status = $"Signed in to Spelunk at {apiBase} as {signIn.Email}. Links from that Spelunk now connect straight away.";
|
||||||
|
return Status;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = "Signing in to Spelunk was cancelled.";
|
||||||
|
return Status;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SpelunkSignInException or SpelunkTunnelException)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
return Status;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsSigningInToSpelunk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Refuses unless DodoSSH is online and its account still signed in — asked of the identity provider
|
||||||
|
/// itself, at most once a minute (see <see cref="IVaultServer.VerifySignInAsync"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// An account the provider refuses is treated as signed out everywhere it reaches: every Spelunk sign-in
|
||||||
|
/// on this device is ended, and the shell forgets the DodoSSH sign-in and goes offline (see
|
||||||
|
/// <c>accountRefused</c>). Anything short of a refusal is a check that could not be made, and a Spelunk
|
||||||
|
/// connection waits for one that can rather than going ahead unchecked.
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="SpelunkTunnelException">Not online, not signed in, or refused; the message says which.</exception>
|
||||||
|
private async Task VerifyDodoSshSignInAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
"Connecting through Spelunk needs DodoSSH online and signed in, so that your account can be "
|
||||||
|
+ "checked first. " + (OfflineReason ?? "This machine is offline."));
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await server.VerifySignInAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// Asked again only for what asking does to a connection already held: the shell writes down the
|
||||||
|
// refresh token the check may just have rotated, which is the only one the next launch can use.
|
||||||
|
// Left for the next sync pass, a crash in between would cost a browser sign-in.
|
||||||
|
await ResolveServerAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (OidcException exception) when (string.Equals(exception.ErrorCode, "invalid_grant", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
await SignOutOfEverySpelunkAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (accountRefused is { } refused)
|
||||||
|
{
|
||||||
|
await refused(exception.Message, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
"Your DodoSSH account no longer signs in, so this device has been signed out of DodoSSH and of "
|
||||||
|
+ "Spelunk. Sign in again from Preferences.");
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not (OperationCanceledException or OutOfMemoryException or SpelunkTunnelException))
|
||||||
|
{
|
||||||
|
throw new SpelunkTunnelException(
|
||||||
|
$"DodoSSH could not check that you are still signed in ({exception.Message}). Connecting through "
|
||||||
|
+ "Spelunk needs that check; try again once the server can be reached.",
|
||||||
|
exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What to reach a host's Spelunk with: this device's sign-in there, once DodoSSH's own has been checked.
|
||||||
|
/// </summary>
|
||||||
|
/// <exception cref="SpelunkTunnelException">There is none, or DodoSSH's own was refused.</exception>
|
||||||
|
private async Task<SpelunkSignInRecord> SpelunkSignInForAsync(
|
||||||
|
HostRowViewModel row,
|
||||||
|
SpelunkRoute route,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await VerifyDodoSshSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
return await session.SpelunkSignIns.FindAsync(route.ApiBase, cancellationToken).ConfigureAwait(true)
|
||||||
|
?? throw new SpelunkTunnelException(
|
||||||
|
$"'{row.Label}' is reached through Spelunk at {route.ApiBase}, and this device is not signed in "
|
||||||
|
+ "there. Sign in under Settings → Security → Spelunk, or from the host's editor.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets a sign-in Spelunk refused, so the next attempt asks for a new one instead of repeating the refusal.</summary>
|
||||||
|
private async Task ForgetRefusedSpelunkSignInAsync(string apiBase, SpelunkTunnelException refusal)
|
||||||
|
{
|
||||||
|
if (refusal.StatusCode != HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await session.SpelunkSignIns.ForgetAsync(apiBase, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
await ReloadSpelunkSignInsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>Read after the hosts, because each row says how many of them reach Spelunk through it.</remarks>
|
||||||
|
internal async Task ReloadSpelunkSignInsAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var signIns = await session.SpelunkSignIns.ListAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
SpelunkSignIns.Clear();
|
||||||
|
|
||||||
|
foreach (var signIn in signIns)
|
||||||
|
{
|
||||||
|
SpelunkSignIns.Add(new SpelunkSignInRowViewModel(
|
||||||
|
signIn,
|
||||||
|
Hosts.Count(row => string.Equals(row.Host.Spelunk?.ApiBase, signIn.ApiBase, StringComparison.Ordinal))));
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(nameof(HasSpelunkSignIns));
|
||||||
|
UpdateEditorSpelunkSignIn(keepNote: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Says, in the host editor, whether this device can reach the Spelunk it names.</summary>
|
||||||
|
/// <param name="keepNote">Whether to leave a sign-in's own outcome in place rather than restating the state.</param>
|
||||||
|
private void UpdateEditorSpelunkSignIn(bool keepNote = false)
|
||||||
|
{
|
||||||
|
if (!SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var apiBase, out _))
|
||||||
|
{
|
||||||
|
EditorSpelunkNeedsSignIn = false;
|
||||||
|
EditorSpelunkSignInNote = string.Empty;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var signIn = SpelunkSignIns.FirstOrDefault(row => string.Equals(row.ApiBase, apiBase, StringComparison.Ordinal));
|
||||||
|
EditorSpelunkNeedsSignIn = signIn is null;
|
||||||
|
|
||||||
|
if (keepNote && EditorSpelunkSignInNote.Length > 0 && signIn is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorSpelunkSignInNote = signIn is null
|
||||||
|
? $"This device is not signed in to Spelunk at {apiBase}. Sign in to connect — Spelunk asks in your browser."
|
||||||
|
: $"This device reaches it as {signIn.Who}.";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1343,9 +1343,6 @@ 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>
|
||||||
@@ -1486,7 +1483,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
ConnectionRecorder? connectionLog = null,
|
ConnectionRecorder? connectionLog = null,
|
||||||
VaultVisibility? visibility = null,
|
VaultVisibility? visibility = null,
|
||||||
ISshReachabilityProbe? reachability = null,
|
ISshReachabilityProbe? reachability = null,
|
||||||
ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable
|
ISpelunkTunnel? spelunk = null,
|
||||||
|
ISpelunkAccounts? spelunkAccounts = null,
|
||||||
|
Func<string, CancellationToken, Task>? accountRefused = 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>
|
||||||
@@ -4251,48 +4250,21 @@ internal sealed partial class VaultViewModel(
|
|||||||
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
|
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
|
||||||
private bool isAskingForConnectPassword;
|
private bool isAskingForConnectPassword;
|
||||||
|
|
||||||
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// One box rather than four, because this is the form of an address people already have: it is what a
|
|
||||||
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
|
|
||||||
/// would make the ordinary case three taps between three keyboards on a phone.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string manualTarget = string.Empty;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ConnectPassword" />
|
|
||||||
/// <remarks>
|
|
||||||
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
|
|
||||||
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
|
|
||||||
/// not a password offered on the other.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string manualPassword = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Why the manual box refused, if it did.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
|
|
||||||
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
|
|
||||||
/// the text box, not on a status line that also carries what the sync engine is doing.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string manualStatus = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal
|
/// <b>The phone's version of the desktop palette's typed-address row.</b> The connect prompt on the
|
||||||
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while
|
/// terminal screen is the other way in, and it is only drawn while nothing is open — a second machine
|
||||||
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the
|
/// reached while a first one's session is up had no box at all. The search well at the top of HOSTS is
|
||||||
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so
|
/// always there, the design already calls it "Search or connect…", and <c>user@host</c> is what people
|
||||||
/// it reads the address rather than the screen growing a second field.
|
/// type into a box, so it reads the address rather than the screen growing a second field.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
|
/// Read with <see cref="TryParseManualTarget"/>, the prompt's and the palette's parser too, and only for a
|
||||||
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
|
/// filter with an <c>@</c> in it: without one, everything typed is a search, and a card offering to dial
|
||||||
/// would arrive on every keystroke.
|
/// three letters would arrive on every keystroke.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -4318,10 +4290,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
|
/// Its own box rather than <see cref="ConnectPassword"/>: these are different screens, and a password
|
||||||
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a
|
/// typed on one is not a password offered on the other. Cleared when the address changes, so the next
|
||||||
/// password offered on the other. Cleared when the address changes, so the next machine typed never
|
/// machine typed never arrives with the last one's password already in the box.
|
||||||
/// arrives with the last one's password already in the box.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string filterAddressPassword = string.Empty;
|
private string filterAddressPassword = string.Empty;
|
||||||
@@ -4378,9 +4349,21 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool SelectedHostAsksForAPassword =>
|
internal bool SelectedHostAsksForAPassword =>
|
||||||
SelectedHost is null
|
SelectedHost is null || AsksForAPassword(SelectedHost);
|
||||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
|
||||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
/// <summary>Whether connecting to this host needs a password typed for it.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The per-row half of <see cref="SelectedHostAsksForAPassword"/>, and the same reading for the same
|
||||||
|
/// reasons: the resolved binding rather than the host's own ids, and never for a host Spelunk signs in to.
|
||||||
|
/// The desktop's terminal prompt asks it of a host named there, which is not the selected one.
|
||||||
|
/// </remarks>
|
||||||
|
internal static bool AsksForAPassword(HostRowViewModel row)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
|
||||||
|
return !row.Host.SignsInThroughSpelunk
|
||||||
|
&& row.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.
|
||||||
@@ -4612,7 +4595,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);
|
await ReloadSpelunkSignInsAsync(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.
|
||||||
@@ -4667,7 +4650,6 @@ 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();
|
||||||
|
|
||||||
@@ -4686,8 +4668,6 @@ 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)
|
||||||
@@ -7808,40 +7788,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
IsHostPaneOpen = true;
|
IsHostPaneOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raises whatever the head in front of the user uses to ask about one host.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="row">The machine, which the caller is holding.</param>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// ◆ <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> The
|
|
||||||
/// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
|
|
||||||
/// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
|
|
||||||
/// that is a different piece of state, so a caller that only opened the pane would leave this head
|
|
||||||
/// arriving at a list with nothing on it to press.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Both are set rather than branching on which head is running, because neither view model knows and
|
|
||||||
/// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
|
|
||||||
/// phone has no drawer.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
|
|
||||||
/// act both flags exist to tell apart from browsing.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private void AskAboutHost(HostRowViewModel? row)
|
|
||||||
{
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OpenHostPane(row);
|
|
||||||
ChooseHost(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts the drawer away, whichever of the three panels is in it.
|
/// Puts the drawer away, whichever of the three panels is in it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -11197,10 +11143,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -11460,11 +11402,41 @@ internal sealed partial class VaultViewModel(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ConnectToHostAsync(row, ConnectPassword, mayRememberPassword: true, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a terminal on a keychain host, with a password typed for it if it needs one.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The body of <see cref="ConnectToSelectedHostAsync"/>, taking the host and the password as arguments so
|
||||||
|
/// the desktop's terminal prompt can name a host without selecting it and type a password without writing
|
||||||
|
/// it into <see cref="ConnectPassword"/> — which is the drawer's box, and would carry a remember-this
|
||||||
|
/// choice the prompt never offered. Every refusal is still made here, once.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The password is ignored for a host whose binding supplies its own credential, as the drawer's box is.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="row">The host to connect to.</param>
|
||||||
|
/// <param name="password">The password typed for it, or empty.</param>
|
||||||
|
/// <param name="mayRememberPassword">See <see cref="ConnectionTarget.MayRememberPassword"/>.</param>
|
||||||
|
/// <param name="cancellationToken">Whatever the caller's own lifetime is.</param>
|
||||||
|
internal async Task ConnectToHostAsync(
|
||||||
|
HostRowViewModel row,
|
||||||
|
string password,
|
||||||
|
bool mayRememberPassword,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(row);
|
||||||
|
|
||||||
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
||||||
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
||||||
// thing and got another, and the host is the last place that would say so.
|
// thing and got another, and the host is the last place that would say so.
|
||||||
if (!TryBuildAuthentication(
|
if (!TryBuildAuthentication(
|
||||||
row.Host, row.Resolved, ConnectPassword, out var authentication, out var refusal))
|
row.Host, row.Resolved, password, out var authentication, out var refusal))
|
||||||
{
|
{
|
||||||
Status = refusal;
|
Status = refusal;
|
||||||
return;
|
return;
|
||||||
@@ -11477,67 +11449,23 @@ internal sealed partial class VaultViewModel(
|
|||||||
IsAskingForConnectPassword = false;
|
IsAskingForConnectPassword = false;
|
||||||
|
|
||||||
await ConnectToAsync(
|
await ConnectToAsync(
|
||||||
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
|
new ConnectionTarget(
|
||||||
|
row.Label,
|
||||||
|
row.Host.Hostname,
|
||||||
|
row.Resolved.Port.Value,
|
||||||
|
row,
|
||||||
|
mayRememberPassword),
|
||||||
authentication,
|
authentication,
|
||||||
cancellationToken).ConfigureAwait(true);
|
cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens a terminal on somewhere that is not in the keychain.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
|
|
||||||
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
|
|
||||||
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
|
|
||||||
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
|
|
||||||
/// been given the address of.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
|
|
||||||
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
|
|
||||||
/// written there at length. A key is a reason to save the host.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
|
|
||||||
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
|
|
||||||
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
|
||||||
/// <see cref="ConnectAsync"/> carries.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
|
||||||
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ConnectManuallyAsync()" />
|
|
||||||
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
|
|
||||||
{
|
|
||||||
ManualStatus = refusal;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ManualPassword.Length == 0)
|
|
||||||
{
|
|
||||||
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ManualStatus = string.Empty;
|
|
||||||
|
|
||||||
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
|
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
|
||||||
/// entry are the ones the manual box and the desktop palette produce, and nothing is written to the
|
/// entry are the ones the connect prompt and the desktop palette produce, and nothing is written to the
|
||||||
/// keychain.
|
/// keychain.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -11607,14 +11535,27 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's
|
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
|
||||||
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into —
|
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
|
||||||
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands
|
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
|
||||||
/// over what it read rather than writing into the phone's fields to have them read back here.
|
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
|
||||||
|
/// been given the address of.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The caller has already refused an unreadable address and an empty password, which is why neither is
|
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
|
||||||
/// checked again: both refusals are sentences about a box, and only the caller knows which box.
|
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
|
||||||
|
/// written there at length. A key is a reason to save the host.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
|
||||||
|
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
|
||||||
|
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts
|
||||||
|
/// search — parses it with <see cref="TryParseManualTarget"/> and hands over what it read. The caller has
|
||||||
|
/// already refused an unreadable address and an empty password, which is why neither is checked again:
|
||||||
|
/// both refusals are sentences about a box, and only the caller knows which box.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal Task ConnectToEndpointAsync(
|
internal Task ConnectToEndpointAsync(
|
||||||
@@ -12328,10 +12269,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||||
// for must not wait on a vault write to appear.
|
// for must not wait on a vault write to appear.
|
||||||
//
|
//
|
||||||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
// Only for a target that came from a keychain host. A machine typed in as an address has nothing
|
||||||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||||||
// screen it is typed on says so.
|
// screen it is typed on says so.
|
||||||
if (target.Row is { } row)
|
if (target is { Row: { } row, MayRememberPassword: true })
|
||||||
{
|
{
|
||||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
@@ -12618,6 +12559,12 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// <param name="Row">
|
/// <param name="Row">
|
||||||
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="MayRememberPassword">
|
||||||
|
/// Whether <see cref="RemembersConnectPassword"/> applies to this attempt. That tick belongs to the
|
||||||
|
/// drawer's password box, so only an attempt whose password came out of that box may act on it — a
|
||||||
|
/// password typed into the desktop's terminal prompt, which offers no such choice, must not be saved
|
||||||
|
/// because the tick happened to be left on in a drawer that is not even open.
|
||||||
|
/// </param>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||||||
@@ -12638,7 +12585,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
string Label,
|
string Label,
|
||||||
string Hostname,
|
string Hostname,
|
||||||
int Port,
|
int Port,
|
||||||
HostRowViewModel? Row = null)
|
HostRowViewModel? Row = null,
|
||||||
|
bool MayRememberPassword = true)
|
||||||
{
|
{
|
||||||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||||||
internal Guid? HostId => Row?.EntityId;
|
internal Guid? HostId => Row?.EntityId;
|
||||||
@@ -12648,7 +12596,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
||||||
/// reached with a key the item names, and a machine typed into the manual box has neither.
|
/// reached with a key the item names, and a machine typed in as an address has neither.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -139,20 +139,41 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
IAsyncDisposable? lifetime,
|
IAsyncDisposable? lifetime,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var info = BuildConnectionInfo(request);
|
||||||
|
|
||||||
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
// Keep-alive here for the reason it is on the shell client: a transfer connection left open while
|
||||||
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
// somebody browses is idle in exactly the same way, and a file listing against a machine that has
|
||||||
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
// since rebooted would otherwise wait on a socket nothing will ever answer.
|
||||||
var client = new SftpClient(BuildConnectionInfo(request))
|
//
|
||||||
|
// ◆ The operation timeout is the connect timeout for the handshake, and nothing afterwards. SSH.NET's
|
||||||
|
// default is to wait for ever, and its ConnectAsync runs the SFTP handshake — the version exchange
|
||||||
|
// and the home directory lookup — synchronously after the login, where the cancellation token is not
|
||||||
|
// read. A host whose login prints anything into the channel (an echo in .bashrc is the classic) sends
|
||||||
|
// text SSH.NET reads as the length of a packet a gigabyte long, and the file browser then said
|
||||||
|
// "Connecting…" for ever. Reset once connected, so a slow write on a slow link is not cut off.
|
||||||
|
var client = new SftpClient(info)
|
||||||
{
|
{
|
||||||
BufferSize = SftpBufferSize,
|
BufferSize = SftpBufferSize,
|
||||||
KeepAliveInterval = KeepAliveInterval,
|
KeepAliveInterval = KeepAliveInterval,
|
||||||
|
OperationTimeout = info.Timeout,
|
||||||
};
|
};
|
||||||
|
|
||||||
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
||||||
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
||||||
// is watching a step list for.
|
// is watching a step list for.
|
||||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
HostKeyGate gate;
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (SshOperationTimeoutException exception) when (info.IsAuthenticated)
|
||||||
|
{
|
||||||
|
throw SftpHandshakeTimedOut(request, info, exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.OperationTimeout = Timeout.InfiniteTimeSpan;
|
||||||
|
|
||||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||||
// on first read, so leaving it to the property would put a round trip behind something that reads
|
// on first read, so leaving it to the property would put a round trip behind something that reads
|
||||||
@@ -172,6 +193,24 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
|
return new SshNetSftpSession(client, gate.Presented!, home, lifetime);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Says what a timeout after the login means for a file-transfer connection.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only after the login: a timeout before it is the network's or the server's, and SSH.NET's own words
|
||||||
|
/// for it are left alone. One after it is the SFTP handshake's — the host let this account in and then
|
||||||
|
/// never answered as a file server — and SSH.NET's "Operation has timed out" would send somebody looking
|
||||||
|
/// at the network for what is nearly always a line of text in a login script on the host.
|
||||||
|
/// </remarks>
|
||||||
|
private static TimeoutException SftpHandshakeTimedOut(
|
||||||
|
SshConnectionRequest request,
|
||||||
|
ConnectionInfo info,
|
||||||
|
SshOperationTimeoutException exception) =>
|
||||||
|
new(
|
||||||
|
$"{request.Host} accepted the login, but its SFTP service did not answer within "
|
||||||
|
+ $"{info.Timeout.TotalSeconds:0} seconds. This is usually a login script on the host that "
|
||||||
|
+ "prints text (an echo in .bashrc, say): SFTP needs the remote shell to stay silent when "
|
||||||
|
+ "it is not interactive.",
|
||||||
|
exception);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -109,6 +109,29 @@ internal sealed class RememberedSignInRow
|
|||||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>This device's sign-in to one Spelunk.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Everything sealed, the Spelunk's address included, in one record bound to its row: which Spelunk somebody
|
||||||
|
/// manages their servers with is not for a stolen laptop to say. There are a handful at most, so finding the
|
||||||
|
/// one for an address opens them all.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Per device, never synced: each device signs in through its own browser, and Spelunk lists and signs out
|
||||||
|
/// each one on its own. Like <see cref="RememberedSignInRow"/>, sign-out deletes every row with the rest of
|
||||||
|
/// the cache.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class SpelunkSignInRow
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The sign-in, sealed under the LocalCacheKey. Ciphertext.</summary>
|
||||||
|
public byte[] Sealed { get; set; } = [];
|
||||||
|
|
||||||
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
|||||||
|
|
||||||
ConfigureUnlockMaterial(modelBuilder);
|
ConfigureUnlockMaterial(modelBuilder);
|
||||||
ConfigureRememberedSignIn(modelBuilder);
|
ConfigureRememberedSignIn(modelBuilder);
|
||||||
|
ConfigureSpelunkSignIns(modelBuilder);
|
||||||
ConfigureVaults(modelBuilder);
|
ConfigureVaults(modelBuilder);
|
||||||
ConfigureItems(modelBuilder);
|
ConfigureItems(modelBuilder);
|
||||||
ConfigureOutbox(modelBuilder);
|
ConfigureOutbox(modelBuilder);
|
||||||
@@ -123,6 +124,15 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
|||||||
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private static void ConfigureSpelunkSignIns(ModelBuilder modelBuilder) =>
|
||||||
|
modelBuilder.Entity<SpelunkSignInRow>(entity =>
|
||||||
|
{
|
||||||
|
entity.ToTable("spelunk_sign_in");
|
||||||
|
entity.HasKey(row => row.Id);
|
||||||
|
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||||
|
entity.Property(row => row.Sealed).IsRequired();
|
||||||
|
});
|
||||||
|
|
||||||
private static void ConfigureVaults(ModelBuilder modelBuilder)
|
private static void ConfigureVaults(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<CachedVaultRow>(entity =>
|
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||||
|
|||||||
@@ -141,7 +141,6 @@ internal static class AadResourceTypes
|
|||||||
SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry,
|
SyncEntityType.ConnectionLogEntry => CryptoSpec.AadResourceType.ConnectionLogEntry,
|
||||||
SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry,
|
SyncEntityType.ActivityLogEntry => CryptoSpec.AadResourceType.ActivityLogEntry,
|
||||||
SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore,
|
SyncEntityType.ObjectStore => CryptoSpec.AadResourceType.ObjectStore,
|
||||||
SyncEntityType.SpelunkKey => CryptoSpec.AadResourceType.SpelunkKey,
|
|
||||||
|
|
||||||
_ => throw new ArgumentOutOfRangeException(
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
|
nameof(entityType), entityType, "No AAD resource type is defined for this entity type."),
|
||||||
|
|||||||
+502
@@ -0,0 +1,502 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ClientCacheContext))]
|
||||||
|
[Migration("20261007152750_AddSpelunkSignIns")]
|
||||||
|
partial class AddSpelunkSignIns
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<long>("ChangeSequence")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("change_sequence");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_deleted");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long?>("SweepMark")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_mark");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasName("pk_item");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "ChangeSequence")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||||
|
|
||||||
|
b.ToTable("item", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "KeyGeneration")
|
||||||
|
.HasName("pk_vault_key_wrap");
|
||||||
|
|
||||||
|
b.ToTable("vault_key_wrap", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<bool>("Hidden")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("hidden");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPersonal")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_personal");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("Permissions")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("permissions");
|
||||||
|
|
||||||
|
b.Property<bool>("RekeyRequired")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("rekey_required");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TeamId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("team_id");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedVaultKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_vault_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_vault");
|
||||||
|
|
||||||
|
b.ToTable("vault", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("Acknowledged")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("acknowledged");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Detail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("detail");
|
||||||
|
|
||||||
|
b.Property<long>("DetectedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("detected_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kind");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_conflict");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "Acknowledged")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.ToTable("conflict", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Sequence")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sequence");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<byte?>("AncestorAadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_aad_version");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AncestorDataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ancestor_data_key_id");
|
||||||
|
|
||||||
|
b.Property<uint?>("AncestorKeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorPayload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_protected_fields");
|
||||||
|
|
||||||
|
b.Property<int?>("AncestorVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_wrapped_data_key");
|
||||||
|
|
||||||
|
b.Property<int>("Attempts")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("attempts");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int?>("ExpectedVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("expected_version");
|
||||||
|
|
||||||
|
b.Property<bool>("IsParked")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_parked");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last_error");
|
||||||
|
|
||||||
|
b.Property<int>("Operation")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<Guid>("OperationId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("operation_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("QueuedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("queued_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("Sequence")
|
||||||
|
.HasName("pk_outbox");
|
||||||
|
|
||||||
|
b.HasIndex("OperationId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_operation_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||||
|
|
||||||
|
b.ToTable("outbox", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("SealedRefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("sealed_refresh_token");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_remembered_sign_in");
|
||||||
|
|
||||||
|
b.ToTable("remembered_sign_in", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.SpelunkSignInRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Sealed")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("sealed");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_spelunk_sign_in");
|
||||||
|
|
||||||
|
b.ToTable("spelunk_sign_in", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<string>("Cursor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("cursor");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPulledAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pulled_at_utc");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPushedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pushed_at_utc");
|
||||||
|
|
||||||
|
b.Property<string>("PulledTypes")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("pulled_types");
|
||||||
|
|
||||||
|
b.Property<long>("ServerTimeSkewMs")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("server_time_skew_ms");
|
||||||
|
|
||||||
|
b.Property<long?>("SweepMark")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_mark");
|
||||||
|
|
||||||
|
b.Property<long>("SweepSeenUpTo")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_seen_up_to");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_sync_state");
|
||||||
|
|
||||||
|
b.ToTable("sync_state", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeviceId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("device_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("device_wrapped_private_key");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("display_name");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("Issuer")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("issuer");
|
||||||
|
|
||||||
|
b.Property<string>("KdfAlgorithm")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("kdf_algorithm");
|
||||||
|
|
||||||
|
b.Property<int>("KdfMemoryKibibytes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_memory_kibibytes");
|
||||||
|
|
||||||
|
b.Property<int>("KdfParallelism")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_parallelism");
|
||||||
|
|
||||||
|
b.Property<int>("KdfPasses")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_passes");
|
||||||
|
|
||||||
|
b.Property<byte[]>("KdfSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("kdf_salt");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("ServerUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("server_url");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("subject");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedPrivateKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_private_key");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_unlock_material");
|
||||||
|
|
||||||
|
b.ToTable("unlock_material", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Adds this device's Spelunk sign-ins, and drops what is left of Spelunk keys (entity type 14), which
|
||||||
|
/// they replaced: their cached items, their outbox entries and their conflicts.
|
||||||
|
/// </summary>
|
||||||
|
public partial class AddSpelunkSignIns : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM item WHERE entity_type = 14;");
|
||||||
|
migrationBuilder.Sql("DELETE FROM outbox WHERE entity_type = 14;");
|
||||||
|
migrationBuilder.Sql("DELETE FROM conflict WHERE entity_type = 14;");
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "spelunk_sign_in",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
@sealed = table.Column<byte[]>(name: "sealed", type: "BLOB", nullable: false),
|
||||||
|
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_spelunk_sign_in", x => x.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "spelunk_sign_in");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -344,6 +344,27 @@ namespace DodoSSH.Client.Storage.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.SpelunkSignInRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Sealed")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("sealed");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_spelunk_sign_in");
|
||||||
|
|
||||||
|
b.ToTable("spelunk_sign_in", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("VaultId")
|
b.Property<Guid>("VaultId")
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage;
|
||||||
|
|
||||||
|
/// <summary>This device's sign-in to one Spelunk, as <see cref="SpelunkSignInStore"/> keeps it.</summary>
|
||||||
|
/// <param name="ApiBase">The Spelunk API's base address, normalised as a host's route stores it.</param>
|
||||||
|
/// <param name="Token">What Spelunk issued this device: sent as the bearer token on every ticket request.</param>
|
||||||
|
/// <param name="SessionId">Spelunk's id for this sign-in, which its person sees under Your account.</param>
|
||||||
|
/// <param name="Email">Who it is signed in as, for saying so.</param>
|
||||||
|
/// <param name="DisplayName">The same, as Spelunk names them.</param>
|
||||||
|
/// <param name="SignedInAtUtc">When this device signed in.</param>
|
||||||
|
public sealed record SpelunkSignInRecord(
|
||||||
|
string ApiBase,
|
||||||
|
string Token,
|
||||||
|
Guid SessionId,
|
||||||
|
string Email,
|
||||||
|
string DisplayName,
|
||||||
|
DateTimeOffset SignedInAtUtc)
|
||||||
|
{
|
||||||
|
/// <summary>Keeps the token out of logs and debugger views.</summary>
|
||||||
|
public override string ToString() => $"{Email} at {ApiBase}";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Spelunks this device is signed in to, one sign-in each, sealed under the LocalCacheKey — see
|
||||||
|
/// <see cref="SpelunkSignInRow"/> for why per device and why sealed whole.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpelunkSignInStore(
|
||||||
|
IDbContextFactory<ClientCacheContext> contexts,
|
||||||
|
LocalCacheProtector protector,
|
||||||
|
TimeProvider clock)
|
||||||
|
{
|
||||||
|
/// <summary>Every sign-in this key can open. One that will not open is skipped, as a stale cache record is.</summary>
|
||||||
|
public async Task<IReadOnlyList<SpelunkSignInRecord>> ListAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = await context.Set<SpelunkSignInRow>()
|
||||||
|
.AsNoTracking()
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.Select(Open)
|
||||||
|
.OfType<SpelunkSignInRecord>()
|
||||||
|
.OrderBy(record => record.ApiBase, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The sign-in for a Spelunk, or null when this device is not signed in to it.</summary>
|
||||||
|
public async Task<SpelunkSignInRecord?> FindAsync(string apiBase, CancellationToken cancellationToken) =>
|
||||||
|
(await ListAsync(cancellationToken).ConfigureAwait(false))
|
||||||
|
.FirstOrDefault(record => string.Equals(record.ApiBase, apiBase, StringComparison.Ordinal));
|
||||||
|
|
||||||
|
/// <summary>Keeps a sign-in, replacing any this device had for the same Spelunk.</summary>
|
||||||
|
public async Task SaveAsync(SpelunkSignInRecord record, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(record);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = await context.Set<SpelunkSignInRow>().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, record.ApiBase, StringComparison.Ordinal)));
|
||||||
|
|
||||||
|
var id = Guid.CreateVersion7();
|
||||||
|
var bytes = Serialize(record);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
context.Add(new SpelunkSignInRow
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Sealed = protector.Protect(CryptoSpec.AadResourceType.SpelunkSignIn, id, bytes),
|
||||||
|
UpdatedAtUtc = clock.GetUtcNow(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets the sign-in for a Spelunk, if this device had one.</summary>
|
||||||
|
public async Task ForgetAsync(string apiBase, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = await context.Set<SpelunkSignInRow>().ToListAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
context.RemoveRange(rows.Where(row => Open(row) is not { } open || string.Equals(open.ApiBase, apiBase, StringComparison.Ordinal)));
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Forgets every Spelunk sign-in on this device.</summary>
|
||||||
|
public async Task ForgetAllAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await context.Set<SpelunkSignInRow>().ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpelunkSignInRecord? Open(SpelunkSignInRow row)
|
||||||
|
{
|
||||||
|
var opened = protector.TryUnprotect(CryptoSpec.AadResourceType.SpelunkSignIn, row.Id, row.Sealed);
|
||||||
|
if (opened is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return Deserialize(opened);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
CryptographicOperations.ZeroMemory(opened);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written by hand rather than through a serializer context: six fields, and nothing here should depend
|
||||||
|
// on reflection the Android build trims away.
|
||||||
|
private static byte[] Serialize(SpelunkSignInRecord record)
|
||||||
|
{
|
||||||
|
using var buffer = new MemoryStream();
|
||||||
|
using (var writer = new Utf8JsonWriter(buffer))
|
||||||
|
{
|
||||||
|
writer.WriteStartObject();
|
||||||
|
writer.WriteString("api", record.ApiBase);
|
||||||
|
writer.WriteString("token", record.Token);
|
||||||
|
writer.WriteString("session", record.SessionId);
|
||||||
|
writer.WriteString("email", record.Email);
|
||||||
|
writer.WriteString("name", record.DisplayName);
|
||||||
|
writer.WriteString("at", record.SignedInAtUtc);
|
||||||
|
writer.WriteEndObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
return buffer.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SpelunkSignInRecord? Deserialize(byte[] bytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var document = JsonDocument.Parse(bytes);
|
||||||
|
var root = document.RootElement;
|
||||||
|
return new SpelunkSignInRecord(
|
||||||
|
root.GetProperty("api").GetString()!,
|
||||||
|
root.GetProperty("token").GetString()!,
|
||||||
|
root.GetProperty("session").GetGuid(),
|
||||||
|
root.GetProperty("email").GetString()!,
|
||||||
|
root.GetProperty("name").GetString()!,
|
||||||
|
root.GetProperty("at").GetDateTimeOffset());
|
||||||
|
}
|
||||||
|
catch (Exception e) when (e is JsonException or KeyNotFoundException or InvalidOperationException or FormatException)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -170,9 +170,6 @@ internal static class ItemKinds
|
|||||||
|
|
||||||
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
|
(SyncEntityType.ObjectStore, static (outbox, conflicts, keyring) =>
|
||||||
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
|
new ItemReconciler<ObjectStoreSecret>(ObjectStoreKind.Instance, outbox, conflicts, keyring)),
|
||||||
|
|
||||||
(SyncEntityType.SpelunkKey, static (outbox, conflicts, keyring) =>
|
|
||||||
new ItemReconciler<SpelunkKeySecret>(SpelunkKeyKind.Instance, outbox, conflicts, keyring)),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/// <summary>The types to ask the server for, in a fixed order.</summary>
|
/// <summary>The types to ask the server for, in a fixed order.</summary>
|
||||||
@@ -1061,82 +1058,3 @@ internal sealed class ObjectStoreKind : IItemKind<ObjectStoreSecret>
|
|||||||
return secret with { Label = label };
|
return secret with { Label = label };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Spelunk keys.</summary>
|
|
||||||
internal sealed class SpelunkKeyKind : IItemKind<SpelunkKeySecret>
|
|
||||||
{
|
|
||||||
internal static SpelunkKeyKind Instance { get; } = new();
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public SyncEntityType EntityType => SyncEntityType.SpelunkKey;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public string Noun => "Spelunk key";
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public OpenedItem<SpelunkKeySecret>? TryOpen(
|
|
||||||
EncryptedPayload payload,
|
|
||||||
ReadOnlySpan<byte> vaultKey,
|
|
||||||
Guid entityId,
|
|
||||||
int itemVersion)
|
|
||||||
{
|
|
||||||
var document = SpelunkKeyCipher.TryOpen(payload, vaultKey, entityId, itemVersion);
|
|
||||||
|
|
||||||
return document is null
|
|
||||||
? null
|
|
||||||
: new OpenedItem<SpelunkKeySecret>(document.Key, document.IsReadOnly);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public EncryptedPayload Seal(
|
|
||||||
SpelunkKeySecret secret,
|
|
||||||
ReadOnlySpan<byte> vaultKey,
|
|
||||||
Guid entityId,
|
|
||||||
uint keyGeneration,
|
|
||||||
int itemVersion) =>
|
|
||||||
SpelunkKeyCipher.Seal(secret, vaultKey, entityId, keyGeneration, itemVersion);
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Nothing. The address is the only candidate and must not go: nothing on the server calls Spelunk.
|
|
||||||
/// </summary>
|
|
||||||
/// <inheritdoc />
|
|
||||||
public SyncPlaintextFields? Fields(SpelunkKeySecret secret) => null;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public MergedItem<SpelunkKeySecret> Merge(
|
|
||||||
SpelunkKeySecret ancestor,
|
|
||||||
SpelunkKeySecret local,
|
|
||||||
SpelunkKeySecret remote)
|
|
||||||
{
|
|
||||||
var merged = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
|
|
||||||
|
|
||||||
return new MergedItem<SpelunkKeySecret>(merged.Merged, merged.Conflicts);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The key is compared and never reported by value, which is the rule every credential-like field in this
|
|
||||||
/// file follows. The address is shown: it is where the key goes, and a change to it is worth reading.
|
|
||||||
/// </remarks>
|
|
||||||
/// <inheritdoc />
|
|
||||||
public IReadOnlyList<string> Changes(SpelunkKeySecret before, SpelunkKeySecret after)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(before);
|
|
||||||
ArgumentNullException.ThrowIfNull(after);
|
|
||||||
|
|
||||||
var changed = new List<string>();
|
|
||||||
|
|
||||||
Note(changed, "Name", before.Label, after.Label);
|
|
||||||
Note(changed, "Spelunk address", before.ApiBase, after.ApiBase);
|
|
||||||
Note(changed, "API key", before.ApiKey, after.ApiKey);
|
|
||||||
|
|
||||||
return changed;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public SpelunkKeySecret Relabel(SpelunkKeySecret secret, string label)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(secret);
|
|
||||||
|
|
||||||
return secret with { Label = label };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using DodoSSH.Client.Domain;
|
|
||||||
using DodoSSH.Contracts;
|
|
||||||
using DodoSSH.Crypto;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.Sync;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Turns a Spelunk key into an item payload and back.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Mirrors <see cref="ObjectStoreCipher"/> exactly, including the version rule in <see cref="SyncVersions"/>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>The resource type is named, never cast.</b> <c>SyncEntityType.SpelunkKey</c> is 14 and
|
|
||||||
/// <c>CryptoSpec.AadResourceType.SpelunkKey</c> is 17; 14 in the crypto enum is a connection log entry, and a
|
|
||||||
/// cast would seal an API key under that — which decrypts perfectly on the machine that wrote it and violates
|
|
||||||
/// docs/crypto.md everywhere else.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public static class SpelunkKeyCipher
|
|
||||||
{
|
|
||||||
private const CryptoSpec.AadResourceType Resource = CryptoSpec.AadResourceType.SpelunkKey;
|
|
||||||
|
|
||||||
/// <summary>Encrypts a Spelunk key.</summary>
|
|
||||||
/// <param name="key">The key. Must be valid for storage.</param>
|
|
||||||
/// <param name="vaultKey">The vault key, which the data key is wrapped under.</param>
|
|
||||||
/// <param name="entityId">The item id, which the AAD binds.</param>
|
|
||||||
/// <param name="keyGeneration">The vault's current key generation.</param>
|
|
||||||
/// <param name="itemVersion">The version this payload will hold once the server accepts it.</param>
|
|
||||||
public static EncryptedPayload Seal(
|
|
||||||
SpelunkKeySecret key,
|
|
||||||
ReadOnlySpan<byte> vaultKey,
|
|
||||||
Guid entityId,
|
|
||||||
uint keyGeneration,
|
|
||||||
int itemVersion)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(key);
|
|
||||||
ArgumentOutOfRangeException.ThrowIfLessThan(itemVersion, 1);
|
|
||||||
|
|
||||||
var plaintext = SpelunkKeySecretCodec.Encode(key);
|
|
||||||
var dataKey = ItemKeys.CreateDataKey();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var dataKeyId = Guid.CreateVersion7();
|
|
||||||
|
|
||||||
var wrappedDataKey = ItemKeys.WrapDataKey(
|
|
||||||
dataKey, vaultKey, Resource, entityId, keyGeneration, (uint)itemVersion);
|
|
||||||
|
|
||||||
var envelope = ItemKeys.SealPayload(
|
|
||||||
dataKey, plaintext, Resource, entityId, dataKeyId, keyGeneration, (uint)itemVersion);
|
|
||||||
|
|
||||||
return new EncryptedPayload(
|
|
||||||
envelope, wrappedDataKey, dataKeyId, keyGeneration, CryptoSpec.CurrentAadVersion);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
CryptographicOperations.ZeroMemory(dataKey);
|
|
||||||
|
|
||||||
// It holds an API key, which is live on the Spelunk it belongs to.
|
|
||||||
CryptographicOperations.ZeroMemory(plaintext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Decrypts a Spelunk key.</summary>
|
|
||||||
/// <inheritdoc cref="HostCipher.TryOpen" path="/returns" />
|
|
||||||
public static SpelunkKeySecretDocument? TryOpen(
|
|
||||||
EncryptedPayload payload,
|
|
||||||
ReadOnlySpan<byte> vaultKey,
|
|
||||||
Guid entityId,
|
|
||||||
int itemVersion)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(payload);
|
|
||||||
|
|
||||||
if (itemVersion < 1 || payload.WrappedDataKey.Length == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var dataKey = ItemKeys.TryUnwrapDataKey(
|
|
||||||
vaultKey,
|
|
||||||
payload.WrappedDataKey,
|
|
||||||
Resource,
|
|
||||||
entityId,
|
|
||||||
payload.KeyGeneration,
|
|
||||||
(uint)itemVersion);
|
|
||||||
|
|
||||||
if (dataKey is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var plaintext = ItemKeys.TryOpenPayload(
|
|
||||||
dataKey,
|
|
||||||
payload.Envelope,
|
|
||||||
Resource,
|
|
||||||
entityId,
|
|
||||||
payload.DataKeyId,
|
|
||||||
payload.KeyGeneration,
|
|
||||||
(uint)itemVersion);
|
|
||||||
|
|
||||||
if (plaintext is null)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return SpelunkKeySecretCodec.TryDecode(plaintext, out var document) ? document : null;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
CryptographicOperations.ZeroMemory(plaintext);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
CryptographicOperations.ZeroMemory(dataKey);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
using DodoSSH.Client.Domain;
|
|
||||||
using DodoSSH.Client.Storage;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.Sync;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The Spelunk keys in this vault, decrypted, with unpushed local changes laid over them.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Another facade over the same generic repository. A key is read when the vault is listed and again at
|
|
||||||
/// connect, from the list, like a credential's password.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class SpelunkKeyRepository(
|
|
||||||
ItemStore items,
|
|
||||||
OutboxStore outbox,
|
|
||||||
VaultKeyring keyring,
|
|
||||||
IActivityLogSink? activity = null)
|
|
||||||
{
|
|
||||||
private readonly VaultItemRepository<SpelunkKeySecret> keys =
|
|
||||||
new(SpelunkKeyKind.Instance, items, outbox, keyring, activity);
|
|
||||||
|
|
||||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.ListAsync" />
|
|
||||||
public Task<ItemListing<SpelunkKeySecret>> ListAsync(
|
|
||||||
Guid vaultId,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
keys.ListAsync(vaultId, cancellationToken);
|
|
||||||
|
|
||||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.CreateAsync" />
|
|
||||||
public Task<Guid> CreateAsync(
|
|
||||||
Guid vaultId,
|
|
||||||
SpelunkKeySecret key,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
keys.CreateAsync(vaultId, key, cancellationToken);
|
|
||||||
|
|
||||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.UpdateAsync" />
|
|
||||||
public Task UpdateAsync(
|
|
||||||
Guid vaultId,
|
|
||||||
Guid entityId,
|
|
||||||
SpelunkKeySecret key,
|
|
||||||
CancellationToken cancellationToken) =>
|
|
||||||
keys.UpdateAsync(vaultId, entityId, key, cancellationToken);
|
|
||||||
|
|
||||||
/// <inheritdoc cref="VaultItemRepository{TSecret}.DeleteAsync" />
|
|
||||||
public Task DeleteAsync(Guid vaultId, Guid entityId, CancellationToken cancellationToken) =>
|
|
||||||
keys.DeleteAsync(vaultId, entityId, cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -219,7 +219,7 @@ public sealed class SyncEngine
|
|||||||
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
|
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
|
||||||
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
|
/// upgrade that adds one — ObjectStore, say — would never receive the items of that type made
|
||||||
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
|
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
|
||||||
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
|
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
|
||||||
/// starts over once for the same reason: it may already have missed some.
|
/// starts over once for the same reason: it may already have missed some.
|
||||||
|
|||||||
@@ -471,7 +471,6 @@ DodoSSH.Contracts.SyncEntityType.KnownHostKey = 10 -> DodoSSH.Contracts.SyncEnti
|
|||||||
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
|
DodoSSH.Contracts.SyncEntityType.ObjectStore = 13 -> DodoSSH.Contracts.SyncEntityType
|
||||||
DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType
|
DodoSSH.Contracts.SyncEntityType.PortForward = 9 -> DodoSSH.Contracts.SyncEntityType
|
||||||
DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType
|
DodoSSH.Contracts.SyncEntityType.Snippet = 8 -> DodoSSH.Contracts.SyncEntityType
|
||||||
DodoSSH.Contracts.SyncEntityType.SpelunkKey = 14 -> DodoSSH.Contracts.SyncEntityType
|
|
||||||
DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType
|
DodoSSH.Contracts.SyncEntityType.SshKey = 3 -> DodoSSH.Contracts.SyncEntityType
|
||||||
DodoSSH.Contracts.SyncEntityType.Tag = 5 -> DodoSSH.Contracts.SyncEntityType
|
DodoSSH.Contracts.SyncEntityType.Tag = 5 -> DodoSSH.Contracts.SyncEntityType
|
||||||
DodoSSH.Contracts.SyncEntityType.Unspecified = 0 -> DodoSSH.Contracts.SyncEntityType
|
DodoSSH.Contracts.SyncEntityType.Unspecified = 0 -> DodoSSH.Contracts.SyncEntityType
|
||||||
|
|||||||
@@ -67,13 +67,6 @@ public enum SyncEntityType
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
ObjectStore = 13,
|
ObjectStore = 13,
|
||||||
|
|
||||||
/// <summary>
|
// 14 was SpelunkKey, a Spelunk API key and its address, removed while still in beta when signing in to
|
||||||
/// A Spelunk API key, and the one Spelunk address it may be sent to.
|
// Spelunk replaced it. Not to be reused: an old client may still send it, and must be refused as unknown.
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// An item of its own rather than a credential's password, because the address is the point: a link from
|
|
||||||
/// Spelunk can be sent by anything on the machine, so a key may only go where the user said it goes, and
|
|
||||||
/// only an item that carries both can say so. See <c>SpelunkKeySecret</c>.
|
|
||||||
/// </remarks>
|
|
||||||
SpelunkKey = 14,
|
|
||||||
}
|
}
|
||||||
@@ -174,8 +174,11 @@ public static class CryptoSpec
|
|||||||
/// <summary>An S3-compatible bucket, and the credentials that reach it.</summary>
|
/// <summary>An S3-compatible bucket, and the credentials that reach it.</summary>
|
||||||
ObjectStore = 16,
|
ObjectStore = 16,
|
||||||
|
|
||||||
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
|
// 17 was SpelunkKey, removed with its item type (SyncEntityType 14). Not to be reused, so no payload
|
||||||
SpelunkKey = 17,
|
// sealed under it can ever open as something else.
|
||||||
|
|
||||||
|
/// <summary>This device's sign-in to one Spelunk. Local cache only; never synced.</summary>
|
||||||
|
SpelunkSignIn = 18,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
/// <summary>HKDF info labels. Domain-separated so one subkey cannot stand in for another.</summary>
|
||||||
|
|||||||
@@ -556,62 +556,6 @@ public sealed class VaultObjectStore : IVaultItem
|
|||||||
public Guid UpdatedByUserId { get; set; }
|
public Guid UpdatedByUserId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A Spelunk API key and the Spelunk address it may be sent to, as ciphertext.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <see cref="VaultObjectStore"/>'s shape, for the same reason: the key is a password, and the address beside
|
|
||||||
/// it is where the user's servers are managed. Nothing on the server calls Spelunk, so there is no plaintext
|
|
||||||
/// column and no argument for one.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class VaultSpelunkKey : IVaultItem
|
|
||||||
{
|
|
||||||
/// <summary>Primary key. UUIDv7, generated by the client so a key can be added offline.</summary>
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Owning vault.</summary>
|
|
||||||
public Guid VaultId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Owning vault.</summary>
|
|
||||||
public Vault? Vault { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The encrypted key and its address: a DSH1 envelope. Opaque to the server.</summary>
|
|
||||||
public byte[] Payload { get; set; } = [];
|
|
||||||
|
|
||||||
/// <summary>The item's data key, wrapped under the vault key. Opaque.</summary>
|
|
||||||
public byte[]? DataKeyWrap { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Reserved for per-item content keys wrapped to individual users; see docs/crypto.md §3.</summary>
|
|
||||||
public Guid? ContentKeyId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Vault key generation this payload was encrypted under.</summary>
|
|
||||||
public int KeyGeneration { get; set; }
|
|
||||||
|
|
||||||
/// <summary>AAD rule version, enabling a lazy re-encrypt-on-write migration later.</summary>
|
|
||||||
public short PayloadAadVersion { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Client-visible, monotonic item version, used for <c>expectedVersion</c> checks.</summary>
|
|
||||||
public int Version { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Latest change-log sequence touching this row, so a delta pull can join directly.</summary>
|
|
||||||
public long ChangeSequence { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Creation timestamp.</summary>
|
|
||||||
public DateTimeOffset CreatedAtUtc { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Last modification timestamp.</summary>
|
|
||||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Soft-delete marker; a tombstone, so an offline client learns the key went away.</summary>
|
|
||||||
public DateTimeOffset? DeletedAtUtc { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Who created it.</summary>
|
|
||||||
public Guid CreatedByUserId { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Who last modified it.</summary>
|
|
||||||
public Guid UpdatedByUserId { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// One connection that was made, as ciphertext.
|
/// One connection that was made, as ciphertext.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -58,8 +58,7 @@ public enum ChangeEntityType
|
|||||||
/// <summary>An S3-compatible bucket, and the credentials that reach it. M2.</summary>
|
/// <summary>An S3-compatible bucket, and the credentials that reach it. M2.</summary>
|
||||||
ObjectStore = 13,
|
ObjectStore = 13,
|
||||||
|
|
||||||
/// <summary>A Spelunk API key, and the one Spelunk address it may be sent to.</summary>
|
// 14 was SpelunkKey, removed with its table; see the RemoveSpelunkKeyItem migration. Not to be reused.
|
||||||
SpelunkKey = 14,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -384,41 +384,6 @@ public sealed class ObjectStoreConfiguration : IEntityTypeConfiguration<VaultObj
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps <see cref="VaultSpelunkKey"/>.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <see cref="ObjectStoreConfiguration"/> again: a key and the address it goes to, neither of which belongs
|
|
||||||
/// in a column.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class SpelunkKeyConfiguration : IEntityTypeConfiguration<VaultSpelunkKey>
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public void Configure(EntityTypeBuilder<VaultSpelunkKey> builder)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(builder);
|
|
||||||
|
|
||||||
builder.ToTable("spelunk_key");
|
|
||||||
builder.HasKey(s => s.Id);
|
|
||||||
|
|
||||||
// Client-generated UUIDv7: a key must be addable offline, with its id.
|
|
||||||
builder.Property(s => s.Id).ValueGeneratedNever();
|
|
||||||
builder.UseXminConcurrencyToken();
|
|
||||||
|
|
||||||
builder.Property(s => s.Payload).IsRequired();
|
|
||||||
|
|
||||||
builder.HasIndex(s => new { s.VaultId, s.ChangeSequence });
|
|
||||||
|
|
||||||
builder.HasIndex(s => s.VaultId)
|
|
||||||
.HasFilter("deleted_at_utc IS NULL")
|
|
||||||
.HasDatabaseName("ix_spelunk_key_vault_live");
|
|
||||||
|
|
||||||
builder.ToTable(t => t.HasCheckConstraint(
|
|
||||||
"ck_spelunk_key_version",
|
|
||||||
"version >= 1"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
/// <summary>Maps <see cref="VaultChange"/>.</summary>
|
||||||
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
public sealed class SyncChangeConfiguration : IEntityTypeConfiguration<VaultChange>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ public class DodoDbContext(DbContextOptions<DodoDbContext> options) : DbContext(
|
|||||||
/// <summary>S3-compatible buckets and their credentials, held as ciphertext.</summary>
|
/// <summary>S3-compatible buckets and their credentials, held as ciphertext.</summary>
|
||||||
public DbSet<VaultObjectStore> ObjectStores => Set<VaultObjectStore>();
|
public DbSet<VaultObjectStore> ObjectStores => Set<VaultObjectStore>();
|
||||||
|
|
||||||
/// <summary>Spelunk API keys and the addresses they may be sent to, held as ciphertext.</summary>
|
|
||||||
public DbSet<VaultSpelunkKey> SpelunkKeys => Set<VaultSpelunkKey>();
|
|
||||||
|
|
||||||
/// <summary>The per-vault change log that delta sync reads.</summary>
|
/// <summary>The per-vault change log that delta sync reads.</summary>
|
||||||
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
public DbSet<VaultChange> VaultChanges => Set<VaultChange>();
|
||||||
|
|
||||||
|
|||||||
+1911
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,80 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Infrastructure.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Removes Spelunk keys, the API keys DodoSSH sent to Spelunk: each device now signs in to Spelunk and
|
||||||
|
/// keeps that sign-in to itself, so there is nothing of Spelunk's left to sync.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Destructive, deliberately: still in beta, and a key has no use without the code that sent it. Their
|
||||||
|
/// change-log entries go too (entity type 14), so no client is ever handed a change of a type it no longer
|
||||||
|
/// knows. Down recreates the empty table and nothing in it.
|
||||||
|
/// </remarks>
|
||||||
|
public partial class RemoveSpelunkKeyItem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql("DELETE FROM dodo.sync_change WHERE entity_type = 14;");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "spelunk_key",
|
||||||
|
schema: "dodo");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "spelunk_key",
|
||||||
|
schema: "dodo",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
vault_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
change_sequence = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
content_key_id = table.Column<Guid>(type: "uuid", nullable: true),
|
||||||
|
created_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
created_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
data_key_wrap = table.Column<byte[]>(type: "bytea", nullable: true),
|
||||||
|
deleted_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true),
|
||||||
|
key_generation = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
payload = table.Column<byte[]>(type: "bytea", nullable: false),
|
||||||
|
payload_aad_version = table.Column<short>(type: "smallint", nullable: false),
|
||||||
|
updated_at_utc = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: false),
|
||||||
|
updated_by_user_id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
version = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
xmin = table.Column<uint>(type: "xid", rowVersion: true, nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_spelunk_key", x => x.id);
|
||||||
|
table.CheckConstraint("ck_spelunk_key_version", "version >= 1");
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "fk_spelunk_key_vaults_vault_id",
|
||||||
|
column: x => x.vault_id,
|
||||||
|
principalSchema: "dodo",
|
||||||
|
principalTable: "vault",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_spelunk_key_vault_id_change_sequence",
|
||||||
|
schema: "dodo",
|
||||||
|
table: "spelunk_key",
|
||||||
|
columns: new[] { "vault_id", "change_sequence" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_spelunk_key_vault_live",
|
||||||
|
schema: "dodo",
|
||||||
|
table: "spelunk_key",
|
||||||
|
column: "vault_id",
|
||||||
|
filter: "deleted_at_utc IS NULL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1491,87 +1491,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<long>("ChangeSequence")
|
|
||||||
.HasColumnType("bigint")
|
|
||||||
.HasColumnName("change_sequence");
|
|
||||||
|
|
||||||
b.Property<Guid?>("ContentKeyId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("content_key_id");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("created_at_utc");
|
|
||||||
|
|
||||||
b.Property<Guid>("CreatedByUserId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("created_by_user_id");
|
|
||||||
|
|
||||||
b.Property<byte[]>("DataKeyWrap")
|
|
||||||
.HasColumnType("bytea")
|
|
||||||
.HasColumnName("data_key_wrap");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("DeletedAtUtc")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("deleted_at_utc");
|
|
||||||
|
|
||||||
b.Property<int>("KeyGeneration")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("key_generation");
|
|
||||||
|
|
||||||
b.Property<byte[]>("Payload")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("bytea")
|
|
||||||
.HasColumnName("payload");
|
|
||||||
|
|
||||||
b.Property<short>("PayloadAadVersion")
|
|
||||||
.HasColumnType("smallint")
|
|
||||||
.HasColumnName("payload_aad_version");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("UpdatedAtUtc")
|
|
||||||
.HasColumnType("timestamp with time zone")
|
|
||||||
.HasColumnName("updated_at_utc");
|
|
||||||
|
|
||||||
b.Property<Guid>("UpdatedByUserId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("updated_by_user_id");
|
|
||||||
|
|
||||||
b.Property<Guid>("VaultId")
|
|
||||||
.HasColumnType("uuid")
|
|
||||||
.HasColumnName("vault_id");
|
|
||||||
|
|
||||||
b.Property<int>("Version")
|
|
||||||
.HasColumnType("integer")
|
|
||||||
.HasColumnName("version");
|
|
||||||
|
|
||||||
b.Property<uint>("xmin")
|
|
||||||
.IsConcurrencyToken()
|
|
||||||
.ValueGeneratedOnAddOrUpdate()
|
|
||||||
.HasColumnType("xid")
|
|
||||||
.HasColumnName("xmin");
|
|
||||||
|
|
||||||
b.HasKey("Id")
|
|
||||||
.HasName("pk_spelunk_key");
|
|
||||||
|
|
||||||
b.HasIndex("VaultId")
|
|
||||||
.HasDatabaseName("ix_spelunk_key_vault_live")
|
|
||||||
.HasFilter("deleted_at_utc IS NULL");
|
|
||||||
|
|
||||||
b.HasIndex("VaultId", "ChangeSequence")
|
|
||||||
.HasDatabaseName("ix_spelunk_key_vault_id_change_sequence");
|
|
||||||
|
|
||||||
b.ToTable("spelunk_key", "dodo", t =>
|
|
||||||
{
|
|
||||||
t.HasCheckConstraint("ck_spelunk_key_version", "version >= 1");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
|
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -1939,18 +1858,6 @@ namespace DodoSSH.Infrastructure.Migrations
|
|||||||
b.Navigation("Vault");
|
b.Navigation("Vault");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("DodoSSH.Domain.VaultSpelunkKey", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("DodoSSH.Domain.Vault", "Vault")
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("VaultId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired()
|
|
||||||
.HasConstraintName("fk_spelunk_key_vaults_vault_id");
|
|
||||||
|
|
||||||
b.Navigation("Vault");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
|
modelBuilder.Entity("DodoSSH.Domain.VaultSshKey", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("DodoSSH.Domain.Vault", "Vault")
|
b.HasOne("DodoSSH.Domain.Vault", "Vault")
|
||||||
|
|||||||
@@ -1186,52 +1186,21 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ASpelunkKey_RoundTripsAsCiphertextWithNoPlaintextAtAll()
|
public async Task ASpelunkKeyFromAnOlderClient_IsRefused_AsATypeThisServerDoesNotSync()
|
||||||
{
|
{
|
||||||
|
// 14 was SpelunkKey, removed when DodoSSH started signing in to Spelunk. An older client may still push
|
||||||
|
// one; it is told so for that item, and nothing of it is kept.
|
||||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
||||||
var client = fixture.CreateClientFor(subject);
|
var client = fixture.CreateClientFor(subject);
|
||||||
|
|
||||||
var keyId = Guid.CreateVersion7();
|
|
||||||
|
|
||||||
var pushed = await client.PostContractAsync(
|
var pushed = await client.PostContractAsync(
|
||||||
PushUrl(vaultId),
|
PushUrl(vaultId),
|
||||||
new SyncPushRequest(
|
new SyncPushRequest([RetiredSpelunkKeyOperation(Guid.CreateVersion7(), [4, 2])]));
|
||||||
[SpelunkKeyOperation(keyId, expectedVersion: null, envelope: [4, 2])]));
|
|
||||||
|
|
||||||
var results = await pushed.Content.ReadContractAsync<SyncPushResponse>();
|
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results.ShouldHaveSingleItem();
|
||||||
results!.Results.ShouldHaveSingleItem().Status.ShouldBe(SyncOperationStatus.Applied);
|
|
||||||
|
|
||||||
var pulled = await client.PostContractAsync(
|
|
||||||
PullUrl(vaultId),
|
|
||||||
new SyncPullRequest(null, null, [SyncEntityType.SpelunkKey]));
|
|
||||||
|
|
||||||
var page = await pulled.Content.ReadContractAsync<SyncPullResponse>();
|
|
||||||
var change = page!.Changes.ShouldHaveSingleItem();
|
|
||||||
|
|
||||||
change.EntityType.ShouldBe(SyncEntityType.SpelunkKey);
|
|
||||||
change.EntityId.ShouldBe(keyId);
|
|
||||||
change.Payload.ShouldNotBeNull().Envelope.ShouldBe([4, 2]);
|
|
||||||
|
|
||||||
change.PlaintextFields.ShouldBeNull("where somebody manages their servers from is not this server's to keep");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ASpelunkKeyCarryingItsAddressInTheClear_IsRejected()
|
|
||||||
{
|
|
||||||
var (subject, vaultId) = await SeedUserWithVaultAsync();
|
|
||||||
var client = fixture.CreateClientFor(subject);
|
|
||||||
|
|
||||||
var operation = SpelunkKeyOperation(Guid.CreateVersion7(), null, [1])
|
|
||||||
with
|
|
||||||
{ PlaintextFields = new SyncPlaintextFields(RelayEnabled: false, Hostname: "api.spelunk.example.com", Port: 443) };
|
|
||||||
|
|
||||||
var pushed = await client.PostContractAsync(PushUrl(vaultId), new SyncPushRequest([operation]));
|
|
||||||
|
|
||||||
var result = (await pushed.Content.ReadContractAsync<SyncPushResponse>())!.Results
|
|
||||||
.ShouldHaveSingleItem();
|
|
||||||
|
|
||||||
result.Status.ShouldBe(SyncOperationStatus.Invalid);
|
result.Status.ShouldBe(SyncOperationStatus.Invalid);
|
||||||
result.Detail.ShouldNotBeNull().ShouldContain("stays encrypted");
|
result.Detail.ShouldNotBeNull().ShouldContain("not yet supported");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -1724,16 +1693,13 @@ public sealed class SyncEndpointTests(ApiFixture fixture)
|
|||||||
Payload(envelope),
|
Payload(envelope),
|
||||||
PlaintextFields: null);
|
PlaintextFields: null);
|
||||||
|
|
||||||
private static SyncPushOperation SpelunkKeyOperation(
|
private static SyncPushOperation RetiredSpelunkKeyOperation(Guid entityId, byte[] envelope) =>
|
||||||
Guid entityId,
|
|
||||||
int? expectedVersion,
|
|
||||||
byte[] envelope) =>
|
|
||||||
new(
|
new(
|
||||||
Guid.CreateVersion7(),
|
Guid.CreateVersion7(),
|
||||||
SyncEntityType.SpelunkKey,
|
(SyncEntityType)14,
|
||||||
entityId,
|
entityId,
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
expectedVersion,
|
ExpectedVersion: null,
|
||||||
Payload(envelope),
|
Payload(envelope),
|
||||||
PlaintextFields: null);
|
PlaintextFields: null);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Headless;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using DodoSSH.Client.App.Views;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Session.Tests;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
using DodoSSH.Client.Ssh;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using DodoSSH.Client.Terminal;
|
||||||
|
using DodoSSH.Crypto;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Layout.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How the empty terminal's prompt answers a keyboard: where the caret goes, and what Enter, Tab and Escape do.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What each key decides is <c>ShellFlowTests</c>' business; this is the half only a real control can show —
|
||||||
|
/// that the caret is on the line that is asking, every time the line that is asking changes. A prompt whose
|
||||||
|
/// password line appeared without the caret on it would take the password into the name it had just read.
|
||||||
|
/// The fixture is the palette suite's, for the palette suite's reasons: a real shell over a real unlocked
|
||||||
|
/// vault, and nothing that reaches a network.
|
||||||
|
/// </remarks>
|
||||||
|
public sealed class ConnectPromptTests : IAsyncLifetime
|
||||||
|
{
|
||||||
|
private const string Passphrase = "a sufficiently long passphrase";
|
||||||
|
private const string ServerUrl = "https://dodossh.example";
|
||||||
|
|
||||||
|
/// <remarks>Far below the shipped profile: nothing here attacks a wrap.</remarks>
|
||||||
|
private static readonly Argon2Profile CheapProfile =
|
||||||
|
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||||
|
|
||||||
|
private readonly FakeAccountServer server = new();
|
||||||
|
private readonly StubKeyBinding keyBinding = new();
|
||||||
|
private readonly VaultKnownHostStore knownHosts = new();
|
||||||
|
|
||||||
|
private ClientCacheFactory caches = null!;
|
||||||
|
private TerminalWorkspace workspace = null!;
|
||||||
|
private VaultSession session = null!;
|
||||||
|
private VaultViewModel vault = null!;
|
||||||
|
private MainWindowViewModel shell = null!;
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask InitializeAsync()
|
||||||
|
{
|
||||||
|
caches = ClientCacheFactory.ForMemory($"prompt-{Guid.CreateVersion7():N}");
|
||||||
|
await caches.MigrateAsync(Token);
|
||||||
|
|
||||||
|
await new AccountProvisioner(server, keyBinding, caches, TimeProvider.System, CheapProfile)
|
||||||
|
.EnrollAsync(ServerUrl, Passphrase, "laptop", "Personal", Token);
|
||||||
|
|
||||||
|
var outcome = await new SessionOpener(caches, TimeProvider.System).UnlockAsync(Passphrase, Token);
|
||||||
|
outcome.IsUnlocked.ShouldBeTrue(outcome.Message);
|
||||||
|
session = outcome.Session!;
|
||||||
|
|
||||||
|
workspace = new TerminalWorkspace(
|
||||||
|
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||||
|
Substitute.For<ISshConnectionFactory>(),
|
||||||
|
TimeProvider.System);
|
||||||
|
|
||||||
|
await knownHosts.OpenAsync(session, Token);
|
||||||
|
|
||||||
|
vault = new VaultViewModel(session, workspace, knownHosts, static () => null);
|
||||||
|
|
||||||
|
await SeedAsync();
|
||||||
|
|
||||||
|
shell = new MainWindowViewModel(
|
||||||
|
ClientPaths.Default,
|
||||||
|
caches,
|
||||||
|
workspace,
|
||||||
|
knownHosts,
|
||||||
|
Substitute.For<IDeviceKeyStore>(),
|
||||||
|
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
||||||
|
TimeProvider.System,
|
||||||
|
// Never asked for a session: the palette searches the host list and connects through the
|
||||||
|
// vault's own command, and nothing on this screen transfers a file.
|
||||||
|
Substitute.For<ISftpSessionFactory>(),
|
||||||
|
CheapProfile)
|
||||||
|
{
|
||||||
|
// The state the palette is only ever open in. Assigned rather than reached through the unlock
|
||||||
|
// path, which would be a second enrollment and a second Argon2 pass for no extra coverage.
|
||||||
|
State = ShellState.Unlocked,
|
||||||
|
Vault = vault,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await shell.DisposeAsync();
|
||||||
|
knownHosts.Close();
|
||||||
|
await workspace.DisposeAsync();
|
||||||
|
await session.DisposeAsync();
|
||||||
|
caches.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePromptTakesTheKeyboardWhenItAppears()
|
||||||
|
{
|
||||||
|
await OnThePromptAsync((prompt, _) => prompt.TargetBox.IsFocused.ShouldBeTrue());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Enter on an address moves the question to the password, and the caret with it — typing straight on is
|
||||||
|
/// what somebody does next, and it has to land in the password box rather than on the end of the address.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EnterOnAnAddressPutsTheCaretOnThePassword()
|
||||||
|
{
|
||||||
|
await OnThePromptAsync((prompt, window) =>
|
||||||
|
{
|
||||||
|
shell.PromptText = "root@10.0.0.5";
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
|
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeTrue();
|
||||||
|
prompt.PasswordBox.IsFocused.ShouldBeTrue("the caret follows the question");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>Escape off the password line puts the caret back on the name, which is still there.</remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EscapeFromThePasswordPutsTheCaretBackOnTheName()
|
||||||
|
{
|
||||||
|
await OnThePromptAsync((prompt, window) =>
|
||||||
|
{
|
||||||
|
shell.PromptText = "root@10.0.0.5";
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||||
|
Relayout(window);
|
||||||
|
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||||
|
prompt.PasswordBox.IsEffectivelyVisible.ShouldBeFalse();
|
||||||
|
prompt.TargetBox.IsFocused.ShouldBeTrue();
|
||||||
|
shell.PromptText.ShouldBe("root@10.0.0.5");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Tab completes rather than moving the focus away, when there is something to complete to — and leaves
|
||||||
|
/// the caret at the end of what it wrote, so Enter is the next key rather than End.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TabCompletesTheNameAndKeepsTheCaretOnIt()
|
||||||
|
{
|
||||||
|
await OnThePromptAsync((prompt, window) =>
|
||||||
|
{
|
||||||
|
shell.PromptText = "host-3";
|
||||||
|
shell.PromptMatches.ShouldNotBeEmpty();
|
||||||
|
|
||||||
|
shell.PromptText = "ost-3";
|
||||||
|
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
|
||||||
|
|
||||||
|
shell.PromptText.ShouldBe("host-3");
|
||||||
|
prompt.TargetBox.IsFocused.ShouldBeTrue("Tab completed rather than moving on");
|
||||||
|
prompt.TargetBox.CaretIndex.ShouldBe("host-3".Length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Helpers ----
|
||||||
|
|
||||||
|
/// <summary>Shows the prompt in a window the size of the terminal's rectangle, and runs one body against it.</summary>
|
||||||
|
private Task OnThePromptAsync(Action<ConnectPrompt, Window> body) =>
|
||||||
|
LayoutHarness.OnTheUiThreadAsync(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.IsConnectPromptShowing.ShouldBeTrue("every case here starts on an empty terminal");
|
||||||
|
|
||||||
|
var prompt = new ConnectPrompt { DataContext = shell };
|
||||||
|
var window = new Window { Content = prompt };
|
||||||
|
LayoutHarness.Settle(window, 900, 600);
|
||||||
|
|
||||||
|
// The posted focus the application's dispatcher would run once the prompt was laid out.
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
body(prompt, window);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
window.Close();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Token);
|
||||||
|
|
||||||
|
/// <summary>Runs the layout pass, and the focus it posts, that the application's dispatcher would.</summary>
|
||||||
|
private static void Relayout(Window window)
|
||||||
|
{
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
window.UpdateLayout();
|
||||||
|
Dispatcher.UIThread.RunJobs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>Enough hosts that a partial name has more than one to match.</remarks>
|
||||||
|
private async Task SeedAsync()
|
||||||
|
{
|
||||||
|
for (var i = 0; i < 6; i++)
|
||||||
|
{
|
||||||
|
vault.NewHostCommand.Execute(null);
|
||||||
|
vault.EditorLabel = $"host-{i}";
|
||||||
|
vault.EditorHostname = $"host-{i}.internal";
|
||||||
|
vault.EditorUsername = "deploy";
|
||||||
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await vault.LoadAsync(Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -233,19 +233,26 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The SPELUNK section with a key saved: the form, its note, and the row listing the key all fit, and the
|
/// The SPELUNK section with this device signed in: the form, and the row listing the sign-in, all fit, and
|
||||||
/// form's button is the real save.
|
/// the form's button is the real sign-in.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheSecurityPageFitsWithASavedSpelunkKey()
|
public async Task TheSecurityPageFitsWhenSignedInToSpelunk()
|
||||||
{
|
{
|
||||||
vault.SpelunkKeyFormApi = "https://api.spelunk.example.com";
|
await vault.Session.SpelunkSignIns.SaveAsync(
|
||||||
vault.SpelunkKeyFormKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyz";
|
new SpelunkSignInRecord(
|
||||||
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
|
"https://api.spelunk.example.com",
|
||||||
|
"spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||||
|
Guid.CreateVersion7(),
|
||||||
|
"laurens@example.com",
|
||||||
|
"Laurens Example",
|
||||||
|
DateTimeOffset.UtcNow),
|
||||||
|
Token);
|
||||||
|
await vault.ReloadSpelunkSignInsAsync(Token);
|
||||||
|
|
||||||
vault.SpelunkKeys.ShouldHaveSingleItem(vault.SpelunkKeyFormNote);
|
vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||||
|
|
||||||
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("with a Spelunk key saved"));
|
await MeasureAsync(() => new SettingsSecurityPage(), faults => faults.ShouldBeEmpty("signed in to Spelunk"));
|
||||||
|
|
||||||
await LayoutHarness.OnTheUiThreadAsync(
|
await LayoutHarness.OnTheUiThreadAsync(
|
||||||
() =>
|
() =>
|
||||||
@@ -257,7 +264,7 @@ public sealed class SettingsPagesLayoutTests : IAsyncLifetime
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
ButtonNamed(window, "SAVE KEY").Command.ShouldBeSameAs(vault.SaveSpelunkKeyCommand);
|
ButtonNamed(window, "SIGN IN").Command.ShouldBeSameAs(vault.SignInToSpelunkCommand);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -122,6 +122,20 @@ internal sealed partial class FakeVaultServer : IVaultServer, IAccountApi, ISync
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public IVaultEventStream Events => Notices;
|
public IVaultEventStream Events => Notices;
|
||||||
|
|
||||||
|
/// <summary>What the identity provider answers when the vault checks the sign-in, or null for yes.</summary>
|
||||||
|
/// <remarks>An <c>OidcException</c> with <c>invalid_grant</c> is an account the provider no longer signs in.</remarks>
|
||||||
|
public Exception? SignInRefusal { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How many times the vault checked the sign-in.</summary>
|
||||||
|
public int SignInChecks { get; private set; }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public Task VerifySignInAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SignInChecks++;
|
||||||
|
return SignInRefusal is { } refusal ? Task.FromException(refusal) : Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The relay a relayed host is reached through. Unavailable unless a test sets one, which is what a
|
/// The relay a relayed host is reached through. Unavailable unless a test sets one, which is what a
|
||||||
/// server that does not advertise the feature supplies.
|
/// server that does not advertise the feature supplies.
|
||||||
|
|||||||
@@ -86,19 +86,86 @@ public sealed class LinuxUpdateChannelTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A machine that cannot say what it has treats everything as newer.
|
/// A machine that cannot say what it has is told so, and is offered nothing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
|
/// This used to read the silence as <c>0</c> and offer everything, on the argument that the cost was
|
||||||
/// an install the package manager declines, and the cost of the other direction is a security fix that
|
/// an install the package manager declined. On a machine with no rpm at all — a pacman package of the
|
||||||
/// never arrives.
|
/// same tree at the same path — that was every nightly, every six hours, as a 56 MB download nothing
|
||||||
|
/// could install. Unknown is not older.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
|
public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything()
|
||||||
{
|
{
|
||||||
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
|
var handed = new List<string>();
|
||||||
|
var channel = Channel(
|
||||||
|
RandomNumberGenerator.GetBytes(16),
|
||||||
|
published: "0.0.1~alpha.0.200-1",
|
||||||
|
installed: null,
|
||||||
|
handToInstaller: handed.Add);
|
||||||
|
|
||||||
(await channel.CheckAsync(Token)).ShouldNotBeNull();
|
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
async () => await channel.DownloadAsync(
|
||||||
|
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"),
|
||||||
|
new Progress<int>(),
|
||||||
|
Token));
|
||||||
|
|
||||||
|
handed.ShouldBeEmpty();
|
||||||
|
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The case that matters: the right path and no rpm — a locally built pacman package that mirrors the
|
||||||
|
/// spec. No binary and no such package both arrive here as null.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy()
|
||||||
|
{
|
||||||
|
var asked = new List<string>();
|
||||||
|
|
||||||
|
var updates = LinuxUpdateChannel.ForInstall(
|
||||||
|
"nightly",
|
||||||
|
package =>
|
||||||
|
{
|
||||||
|
asked.Add(package);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
() => throw new InvalidOperationException("Built a channel for a copy rpm does not own."));
|
||||||
|
|
||||||
|
asked.ShouldBe(["dodossh-nightly"]);
|
||||||
|
updates.IsSupported.ShouldBeFalse();
|
||||||
|
|
||||||
|
var reason = updates.ShouldBeOfType<DodoSSH.Client.Session.UnavailableUpdateChannel>().Reason;
|
||||||
|
|
||||||
|
reason.ShouldNotBeNull();
|
||||||
|
reason.ShouldContain("outside the dodossh-nightly RPM");
|
||||||
|
reason.ShouldContain("package manager that installed it");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACopyRpmOwns_GetsTheChannel()
|
||||||
|
{
|
||||||
|
var asked = new List<string>();
|
||||||
|
var owned = new FakeUpdateChannel();
|
||||||
|
|
||||||
|
var updates = LinuxUpdateChannel.ForInstall(
|
||||||
|
"release",
|
||||||
|
package =>
|
||||||
|
{
|
||||||
|
asked.Add(package);
|
||||||
|
|
||||||
|
return "0.0.1-1";
|
||||||
|
},
|
||||||
|
() => owned);
|
||||||
|
|
||||||
|
asked.ShouldBe(["dodossh"]);
|
||||||
|
updates.ShouldBeSameAs(owned);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -152,13 +152,11 @@ public sealed partial class ShellFlowTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ATypedInAddress_StartsNoForwards()
|
public async Task ATypedInAddress_StartsNoForwards()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@10.0.0.9";
|
await ConnectAtThePromptAsync("deploy@10.0.0.9", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
|
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
|
||||||
ssh.Forwards.ShouldBeEmpty();
|
ssh.Forwards.ShouldBeEmpty();
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ using System.Net;
|
|||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Auth;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
using DodoSSH.Client.Ssh;
|
using DodoSSH.Client.Ssh;
|
||||||
|
|
||||||
@@ -19,22 +21,25 @@ namespace DodoSSH.Client.App.Tests;
|
|||||||
/// own HTTP and WebSocket half is <c>SpelunkTunnelTests</c>' job.
|
/// own HTTP and WebSocket half is <c>SpelunkTunnelTests</c>' job.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
|
/// What these hold is what the tick box, the sign-in and the link promise: a Spelunk host is never dialled
|
||||||
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in
|
/// directly and never probed, a refusal is said in Spelunk's terms, this device's sign-in to a Spelunk goes
|
||||||
/// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects
|
/// only to that Spelunk and only once DodoSSH's own account has been checked, a sign-in connects with the
|
||||||
/// straight away but never sends a key to a Spelunk no saved host already sends it to.
|
/// tunnel's key and Spelunk's account and nothing of the host's, and a link connects straight away but never
|
||||||
|
/// starts a sign-in by itself.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed partial class ShellFlowTests
|
public sealed partial class ShellFlowTests
|
||||||
{
|
{
|
||||||
private const string SpelunkApi = "https://api.spelunk.example.com";
|
private const string SpelunkApi = "https://api.spelunk.example.com";
|
||||||
|
|
||||||
private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
private const string SpelunkToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||||
|
|
||||||
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||||
|
|
||||||
private readonly FakeSpelunkTunnel spelunkTunnels = new();
|
private readonly FakeSpelunkTunnel spelunkTunnels = new();
|
||||||
|
|
||||||
|
private readonly FakeSpelunkAccounts spelunkAccounts = new();
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself()
|
public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself()
|
||||||
{
|
{
|
||||||
@@ -43,9 +48,11 @@ public sealed partial class ShellFlowTests
|
|||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
await vault.ConnectCommand.ExecuteAsync(null);
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
// The key from the credential, to the Spelunk and server the host names, for the host's own port.
|
// This device's sign-in, to the Spelunk and server the host names, for the host's own port — and only
|
||||||
|
// once DodoSSH's own account has been checked.
|
||||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||||
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
|
||||||
|
server.SignInChecks.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
// Through the loopback proxy, and named as the host, because the name is what its key is pinned under.
|
// Through the loopback proxy, and named as the host, because the name is what its key is pinned under.
|
||||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
@@ -127,23 +134,22 @@ public sealed partial class ShellFlowTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ASpelunkHostWithNoKeyChosen_IsNotSaved()
|
public async Task AHostOnASpelunkThisDeviceIsNotSignedInTo_IsRefusedAtConnect_AndOffersTheSignIn()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
var vault = shell.Vault!;
|
await vault.Session.SpelunkSignIns.ForgetAllAsync(Token);
|
||||||
|
await vault.ReloadSpelunkSignInsAsync(Token);
|
||||||
|
|
||||||
vault.NewHostCommand.Execute(null);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
vault.EditorLabel = "web-01";
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
vault.EditorHostname = "203.0.113.10";
|
|
||||||
vault.EditorUsername = "deploy";
|
|
||||||
vault.EditorThroughSpelunk = true;
|
|
||||||
vault.EditorSpelunkApi = SpelunkApi;
|
|
||||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
|
||||||
|
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
|
||||||
|
|
||||||
vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key");
|
vault.EditSelectedHostCommand.Execute(null);
|
||||||
vault.Hosts.ShouldBeEmpty();
|
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||||
|
vault.EditorSpelunkSignInNote.ShouldContain("not signed in to Spelunk");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -156,22 +162,8 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||||
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
|
||||||
}
|
vault.EditorSpelunkSignInNote.ShouldContain("ops@example.com");
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ACredentialHoldingASpelunkKey_IsInUse_WhereverThatIsDrawn()
|
|
||||||
{
|
|
||||||
// So deleting it is warned about like any other credential a host needs, rather than looking unused.
|
|
||||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
|
||||||
|
|
||||||
vault.ShowSectionCommand.Execute(VaultSection.All);
|
|
||||||
var row = vault.VaultItems.Single(item => string.Equals(item.Name, "spelunk key", StringComparison.Ordinal));
|
|
||||||
|
|
||||||
row.UsedBySummary.ShouldBe("web-01");
|
|
||||||
|
|
||||||
vault.SelectedVaultItem = row;
|
|
||||||
vault.SelectedItemUsedByHosts.ShouldHaveSingleItem().Label.ShouldBe("web-01");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Signing in through Spelunk ----
|
// ---- Signing in through Spelunk ----
|
||||||
@@ -188,7 +180,7 @@ public sealed partial class ShellFlowTests
|
|||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
await vault.ConnectCommand.ExecuteAsync(null);
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
|
||||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||||
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
|
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
|
||||||
|
|
||||||
@@ -201,7 +193,7 @@ public sealed partial class ShellFlowTests
|
|||||||
|
|
||||||
shell.IsTerminalShowing.ShouldBeTrue();
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
shell.Tabs.ShouldHaveSingleItem();
|
shell.Tabs.ShouldHaveSingleItem();
|
||||||
vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in");
|
vault.Credentials.ShouldBeEmpty("nothing is remembered for a sign-in");
|
||||||
|
|
||||||
await request.Proxy.Lifetime.DisposeAsync();
|
await request.Proxy.Lifetime.DisposeAsync();
|
||||||
}
|
}
|
||||||
@@ -319,11 +311,11 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||||
|
|
||||||
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
|
// Not signed in to that Spelunk, and a link never starts a sign-in by itself: the editor offers it.
|
||||||
// for it would be offering to send the key wherever a link says.
|
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||||
vault.EditorSelectedSpelunkKey.ShouldBeNull();
|
vault.Status.ShouldNotBeNull().ShouldContain("Sign in to that Spelunk");
|
||||||
vault.Status.ShouldNotBeNull().ShouldContain("first server");
|
|
||||||
|
|
||||||
|
spelunkAccounts.SignedIn.ShouldBeEmpty();
|
||||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||||
ssh.Requests.ShouldBeEmpty();
|
ssh.Requests.ShouldBeEmpty();
|
||||||
vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it");
|
vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it");
|
||||||
@@ -353,9 +345,11 @@ public sealed partial class ShellFlowTests
|
|||||||
chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag),
|
chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag),
|
||||||
chip => chip.IsWorn.ShouldBeTrue());
|
chip => chip.IsWorn.ShouldBeTrue());
|
||||||
|
|
||||||
// So once it is saved, the next link for that server finds it rather than adding a second one.
|
// Signed in from the editor and saved, the next link for that server finds it rather than adding a
|
||||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
// second one.
|
||||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null);
|
||||||
|
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
|
||||||
|
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
@@ -376,10 +370,10 @@ public sealed partial class ShellFlowTests
|
|||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
await vault.OpenSpelunkLinkAsync(Link(host: null, server: other));
|
await vault.OpenSpelunkLinkAsync(Link(host: null, server: other));
|
||||||
|
|
||||||
vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk");
|
vault.IsEditing.ShouldBeFalse("nothing to choose: this device is signed in to this Spelunk");
|
||||||
|
|
||||||
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the
|
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk, and no
|
||||||
// key that Spelunk already gets, and no login of its own.
|
// login of its own.
|
||||||
var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other);
|
var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other);
|
||||||
added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
|
added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
|
||||||
added.Label.ShouldBe("web 01");
|
added.Label.ShouldBe("web 01");
|
||||||
@@ -387,7 +381,6 @@ public sealed partial class ShellFlowTests
|
|||||||
added.Host.Port.ShouldBe(22);
|
added.Host.Port.ShouldBe(22);
|
||||||
added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
|
added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
|
||||||
added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue();
|
added.Host.Spelunk.SignInThroughSpelunk.ShouldBeTrue();
|
||||||
added.Host.Spelunk.ApiKeyCredentialId.ShouldBe(vault.Credentials.Single(row => row.Label == "spelunk key").EntityId);
|
|
||||||
added.Host.SshKeyId.ShouldBeNull();
|
added.Host.SshKeyId.ShouldBeNull();
|
||||||
added.Host.CredentialId.ShouldBeNull();
|
added.Host.CredentialId.ShouldBeNull();
|
||||||
added.Host.Username.ShouldBeNull();
|
added.Host.Username.ShouldBeNull();
|
||||||
@@ -395,7 +388,7 @@ public sealed partial class ShellFlowTests
|
|||||||
|
|
||||||
// And connected, through Spelunk, signing in.
|
// And connected, through Spelunk, signing in.
|
||||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||||
opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22));
|
opened.ShouldBe((SpelunkApi, other, SpelunkToken, 22));
|
||||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||||
|
|
||||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
@@ -507,70 +500,45 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel");
|
vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- A Spelunk key saved on the Security page ----
|
// ---- Signing this device in to Spelunk ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AKeySavedOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway()
|
public async Task ASignInOnTheSecurityPage_LetsTheFirstLinkConnectStraightAway()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await SaveSpelunkKeyAsync(vault, SpelunkApi + "/", SpelunkApiKey);
|
await SignInToSpelunkAsync(vault, SpelunkApi + "/");
|
||||||
|
|
||||||
// Saved in the one form, in the personal vault, and gone from the form.
|
// Signed in through the browser, for the address in its one form, and kept on this device.
|
||||||
var saved = vault.SpelunkKeys.ShouldHaveSingleItem();
|
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
|
||||||
saved.ApiBase.ShouldBe(SpelunkApi);
|
var signedIn = vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||||
saved.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
|
signedIn.ApiBase.ShouldBe(SpelunkApi);
|
||||||
vault.SpelunkKeyFormKey.ShouldBeEmpty();
|
signedIn.Who.ShouldContain("ops@example.com");
|
||||||
|
vault.SpelunkSignInFormNote.ShouldContain("Signed in");
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||||
|
|
||||||
vault.IsEditing.ShouldBeFalse("saving the key for this Spelunk is the say-so the first link used to ask for");
|
vault.IsEditing.ShouldBeFalse("signing in to this Spelunk is the say-so a link needs");
|
||||||
|
|
||||||
var added = vault.Hosts.ShouldHaveSingleItem();
|
var added = vault.Hosts.ShouldHaveSingleItem();
|
||||||
added.Host.Spelunk.ShouldNotBeNull().ApiKeyCredentialId.ShouldBe(saved.EntityId);
|
|
||||||
added.Host.SignsInThroughSpelunk.ShouldBeTrue();
|
added.Host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||||
|
|
||||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkToken, 22));
|
||||||
|
|
||||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ASecondKeyForTheSameSpelunk_ReplacesTheFirst_AndHostsUseTheNewOne()
|
public async Task ASignIn_IsNeverSentToAnotherSpelunk()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, "spelunk_the_old_key");
|
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
// A host on a Spelunk this device did not sign in to: the sign-in it has is for somewhere else.
|
||||||
await vault.OpenSpelunkLinkAsync(Link());
|
|
||||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
|
||||||
|
|
||||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
|
||||||
|
|
||||||
vault.SpelunkKeys.ShouldHaveSingleItem().UsedBy.ShouldBe("1 host uses it");
|
|
||||||
vault.SpelunkKeyFormNote.ShouldContain("Replaced");
|
|
||||||
|
|
||||||
await vault.OpenSpelunkLinkAsync(Link());
|
|
||||||
|
|
||||||
spelunkTunnels.Opened.Select(opened => opened.ApiKey).ShouldBe(["spelunk_the_old_key", SpelunkApiKey]);
|
|
||||||
|
|
||||||
await ssh.Requests[1].Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ASavedKey_IsNeverSentToAnotherSpelunk()
|
|
||||||
{
|
|
||||||
await UnlockedAsync();
|
|
||||||
var vault = shell.Vault!;
|
|
||||||
|
|
||||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
|
||||||
var saved = vault.SpelunkKeys.ShouldHaveSingleItem();
|
|
||||||
|
|
||||||
// A host that names the key but another address — the one thing a key kept with its address refuses.
|
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
vault.EditorLabel = "web-01";
|
vault.EditorLabel = "web-01";
|
||||||
vault.EditorHostname = "203.0.113.10";
|
vault.EditorHostname = "203.0.113.10";
|
||||||
@@ -578,7 +546,7 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.EditorSpelunkSignIn = true;
|
vault.EditorSpelunkSignIn = true;
|
||||||
vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example";
|
vault.EditorSpelunkApi = "https://spelunk.somewhere-else.example";
|
||||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.EntityId == saved.EntityId);
|
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
|
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
|
||||||
@@ -588,85 +556,198 @@ public sealed partial class ShellFlowTests
|
|||||||
|
|
||||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||||
ssh.Requests.ShouldBeEmpty();
|
ssh.Requests.ShouldBeEmpty();
|
||||||
vault.Status.ShouldNotBeNull().ShouldContain("never sent anywhere else");
|
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TypingTheAddressOfASavedKey_PicksThatKey_InTheEditor()
|
public async Task AnAddressThatWouldSendTheSignInInClear_IsRefusedOnTheForm()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await AddCredentialAsync(vault, "an older key", password: "spelunk_older");
|
await SignInToSpelunkAsync(vault, "http://api.spelunk.example.com");
|
||||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
|
||||||
|
spelunkAccounts.SignedIn.ShouldBeEmpty("no browser is opened for an address that would be refused");
|
||||||
|
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||||
|
vault.SpelunkSignInFormNote.ShouldContain("https");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ASignInCancelledInTheBrowser_IsSaid_AndNothingIsKept()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
|
spelunkAccounts.Refusal = new SpelunkSignInException("Signing in to Spelunk was cancelled in the browser.");
|
||||||
|
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||||
|
|
||||||
|
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||||
|
vault.SpelunkSignInFormNote.ShouldContain("cancelled in the browser");
|
||||||
|
vault.IsSigningInToSpelunk.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TheEditor_SaysWhoItReachesSpelunkAs_AndSignsInFromThere()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
vault.EditorThroughSpelunk = true;
|
vault.EditorThroughSpelunk = true;
|
||||||
vault.EditorSpelunkApi = SpelunkApi + "/";
|
vault.EditorSpelunkApi = SpelunkApi + "/";
|
||||||
|
|
||||||
var chosen = vault.EditorSelectedSpelunkKey.ShouldNotBeNull();
|
vault.EditorSpelunkNeedsSignIn.ShouldBeTrue();
|
||||||
chosen.EntityId.ShouldBe(vault.SpelunkKeys.Single().EntityId);
|
|
||||||
chosen.Qualifier.ShouldBe($"Spelunk key · {SpelunkApi}");
|
|
||||||
|
|
||||||
// Saved keys come before credentials, which stay on offer for keys kept the older way.
|
await vault.SignInToEditorSpelunkCommand.ExecuteAsync(null);
|
||||||
vault.EditorSpelunkKeyChoices.Select(choice => choice.Label)
|
|
||||||
.ShouldBe(["Spelunk at api.spelunk.example.com", "an older key"]);
|
spelunkAccounts.SignedIn.ShouldBe([SpelunkApi]);
|
||||||
|
vault.EditorSpelunkNeedsSignIn.ShouldBeFalse();
|
||||||
|
vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnAddressThatWouldSendTheKeyInClear_IsRefusedOnTheForm()
|
public async Task SpelunkSigningThisDeviceOut_ForgetsTheSignIn_SoTheNextConnectAsksForANewOne()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
spelunkTunnels.Refusal = new SpelunkTunnelException(
|
||||||
var vault = shell.Vault!;
|
"Spelunk signed this device out: your password changed.", HttpStatusCode.Unauthorized);
|
||||||
|
|
||||||
await SaveSpelunkKeyAsync(vault, "http://api.spelunk.example.com", SpelunkApiKey);
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
vault.SpelunkKeys.ShouldBeEmpty();
|
|
||||||
vault.SpelunkKeyFormNote.ShouldContain("https");
|
|
||||||
vault.SpelunkKeyFormKey.ShouldBe(SpelunkApiKey, "a refused key stays in the box to be saved once the address is fixed");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task DeletingASavedKey_SaysWhichHostsWillStopConnecting()
|
|
||||||
{
|
|
||||||
await UnlockedAsync();
|
|
||||||
var vault = shell.Vault!;
|
|
||||||
|
|
||||||
await SaveSpelunkKeyAsync(vault, SpelunkApi, SpelunkApiKey);
|
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
await vault.OpenSpelunkLinkAsync(Link());
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
|
||||||
|
|
||||||
vault.DeleteSpelunkKeyRowCommand.Execute(vault.SpelunkKeys.Single());
|
vault.Status.ShouldNotBeNull().ShouldContain("signed this device out");
|
||||||
|
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||||
|
(await vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty();
|
||||||
|
|
||||||
var request = vault.PendingDeletion.ShouldNotBeNull();
|
// The next attempt does not repeat the refusal: it asks to sign in.
|
||||||
request.Usage.ShouldContain("web 01 reaches Spelunk with it");
|
spelunkTunnels.Refusal = null;
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
await vault.ConfirmDeleteCommand.ExecuteAsync(null);
|
spelunkTunnels.Opened.Count.ShouldBe(1);
|
||||||
|
vault.Status.ShouldNotBeNull().ShouldContain("not signed in there");
|
||||||
vault.SpelunkKeys.ShouldBeEmpty();
|
|
||||||
|
|
||||||
await vault.ConnectToRowCommand.ExecuteAsync(vault.Hosts.Single());
|
|
||||||
vault.Status.ShouldNotBeNull().ShouldContain("not in this keychain any more");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task SaveSpelunkKeyAsync(VaultViewModel vault, string apiBase, string apiKey)
|
[Fact]
|
||||||
|
public async Task AServerOrOrganisationSpelunkRefuses_KeepsTheSignIn()
|
||||||
{
|
{
|
||||||
vault.SpelunkKeyFormApi = apiBase;
|
// A 403 is about this server, not about who is signed in: every other server stays reachable.
|
||||||
vault.SpelunkKeyFormKey = apiKey;
|
spelunkTunnels.Refusal = new SpelunkTunnelException(
|
||||||
|
"You are not allowed to open a terminal on this server.", HttpStatusCode.Forbidden);
|
||||||
|
|
||||||
await vault.SaveSpelunkKeyCommand.ExecuteAsync(null);
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
vault.Status.ShouldNotBeNull().ShouldContain("not allowed");
|
||||||
|
vault.SpelunkSignIns.ShouldHaveSingleItem();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain.</summary>
|
[Fact]
|
||||||
|
public async Task ADodoSshAccountTheProviderRefuses_ReachesNoSpelunk_AndSignsThisDeviceOut()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
|
server.SignInRefusal = new OidcException("The account is disabled.", "invalid_grant");
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
spelunkTunnels.Opened.ShouldBeEmpty("Spelunk is not reached for an account DodoSSH no longer has");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
vault.Status.ShouldNotBeNull().ShouldContain("no longer signs in");
|
||||||
|
|
||||||
|
// Signed out of every Spelunk, there and here, and of DodoSSH: offline, and saying why.
|
||||||
|
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||||
|
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||||
|
shell.IsOnline.ShouldBeFalse();
|
||||||
|
vault.OfflineReason.ShouldNotBeNull().ShouldContain("refused");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ADodoSshSignInThatCannotBeChecked_ReachesNoSpelunk_ButIsKept()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
|
server.SignInRefusal = new HttpRequestException("The identity provider could not be reached.");
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||||
|
vault.Status.ShouldNotBeNull().ShouldContain("could not check");
|
||||||
|
vault.SpelunkSignIns.ShouldHaveSingleItem("a provider that is down is not an account that is gone");
|
||||||
|
shell.IsOnline.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ADodoSshSignInRefusedOnResume_SignsThisDeviceOutOfEverySpelunkToo()
|
||||||
|
{
|
||||||
|
await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
await shell.Vault!.SyncOnOpenAsync(Token);
|
||||||
|
await shell.LockCommand.ExecuteAsync(null);
|
||||||
|
spelunkAccounts.SignedOut.ShouldBeEmpty();
|
||||||
|
|
||||||
|
resumeFailure = new OidcException("The token endpoint returned 400: Account disabled.", "invalid_grant");
|
||||||
|
|
||||||
|
var relaunch = Relaunch(resume: ResumeAsync);
|
||||||
|
await using var _ = relaunch.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await relaunch.StartAsync(Token);
|
||||||
|
relaunch.Passphrase = Passphrase;
|
||||||
|
await relaunch.UnlockCommand.ExecuteAsync(null);
|
||||||
|
await relaunch.Vault.ShouldNotBeNull().SyncOnOpenAsync(Token);
|
||||||
|
|
||||||
|
// The token Spelunk is told to end is the one the first launch kept: the sign-in outlived the lock and
|
||||||
|
// the relaunch, and went only when DodoSSH's own did.
|
||||||
|
relaunch.IsOnline.ShouldBeFalse();
|
||||||
|
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||||
|
relaunch.Vault.SpelunkSignIns.ShouldBeEmpty();
|
||||||
|
(await relaunch.Vault.Session.SpelunkSignIns.ListAsync(Token)).ShouldBeEmpty();
|
||||||
|
relaunch.Vault.Status.ShouldNotBeNull().ShouldContain("signed out of Spelunk");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SigningOutOfSpelunk_OnTheSecurityPage_EndsItThereAndHere()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
|
await vault.SignOutOfSpelunkCommand.ExecuteAsync(vault.SpelunkSignIns.Single());
|
||||||
|
|
||||||
|
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||||
|
vault.SpelunkSignIns.ShouldBeEmpty();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SigningOutOfDodoSsh_SignsOutOfEverySpelunk()
|
||||||
|
{
|
||||||
|
await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
|
shell.SignOutCommand.Execute(null);
|
||||||
|
await shell.ConfirmSignOutCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
spelunkAccounts.SignedOut.ShouldBe([SpelunkToken]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task SignInToSpelunkAsync(VaultViewModel vault, string apiBase)
|
||||||
|
{
|
||||||
|
vault.SpelunkSignInFormApi = apiBase;
|
||||||
|
|
||||||
|
await vault.SignInToSpelunkCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A saved host through Spelunk, bound to a stored SSH password, on a Spelunk this device is signed in to.</summary>
|
||||||
private async Task<VaultViewModel> ReadyToConnectThroughSpelunkAsync()
|
private async Task<VaultViewModel> ReadyToConnectThroughSpelunkAsync()
|
||||||
{
|
{
|
||||||
await UnlockedAsync();
|
await UnlockedAsync();
|
||||||
|
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||||
await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy");
|
await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy");
|
||||||
|
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
@@ -676,7 +757,6 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.EditorThroughSpelunk = true;
|
vault.EditorThroughSpelunk = true;
|
||||||
vault.EditorSpelunkApi = SpelunkApi + "/";
|
vault.EditorSpelunkApi = SpelunkApi + "/";
|
||||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
|
||||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||||
.Single(choice => choice.Label == "deploy password");
|
.Single(choice => choice.Label == "deploy password");
|
||||||
|
|
||||||
@@ -697,7 +777,7 @@ public sealed partial class ShellFlowTests
|
|||||||
|
|
||||||
var vault = shell.Vault!;
|
var vault = shell.Vault!;
|
||||||
|
|
||||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
await SignInToSpelunkAsync(vault, SpelunkApi);
|
||||||
|
|
||||||
vault.NewHostCommand.Execute(null);
|
vault.NewHostCommand.Execute(null);
|
||||||
vault.EditorLabel = "web-01";
|
vault.EditorLabel = "web-01";
|
||||||
@@ -706,7 +786,6 @@ public sealed partial class ShellFlowTests
|
|||||||
vault.EditorSpelunkSignIn = true;
|
vault.EditorSpelunkSignIn = true;
|
||||||
vault.EditorSpelunkApi = SpelunkApi;
|
vault.EditorSpelunkApi = SpelunkApi;
|
||||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
|
||||||
|
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
@@ -748,7 +827,7 @@ public sealed partial class ShellFlowTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
|
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
|
||||||
{
|
{
|
||||||
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
|
internal List<(string ApiBase, Guid ServerId, string Token, int Port)> Opened { get; } = [];
|
||||||
|
|
||||||
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
|
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
|
||||||
internal List<SpelunkSignIn?> SignIns { get; } = [];
|
internal List<SpelunkSignIn?> SignIns { get; } = [];
|
||||||
@@ -764,12 +843,12 @@ public sealed partial class ShellFlowTests
|
|||||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||||
string apiBase,
|
string apiBase,
|
||||||
Guid serverId,
|
Guid serverId,
|
||||||
string apiKey,
|
string token,
|
||||||
int port,
|
int port,
|
||||||
SpelunkSignIn? signIn,
|
SpelunkSignIn? signIn,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Opened.Add((apiBase, serverId, apiKey, port));
|
Opened.Add((apiBase, serverId, token, port));
|
||||||
SignIns.Add(signIn);
|
SignIns.Add(signIn);
|
||||||
|
|
||||||
if (Refusal is { } refusal)
|
if (Refusal is { } refusal)
|
||||||
@@ -806,4 +885,32 @@ public sealed partial class ShellFlowTests
|
|||||||
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero }));
|
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Signing in to Spelunk as far as the shell can see it: no browser, a sign-in or a refusal.</summary>
|
||||||
|
private sealed class FakeSpelunkAccounts : ISpelunkAccounts
|
||||||
|
{
|
||||||
|
/// <summary>The Spelunks a sign-in was asked for, in order.</summary>
|
||||||
|
internal List<string> SignedIn { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The tokens that were signed out, in order.</summary>
|
||||||
|
internal List<string> SignedOut { get; } = [];
|
||||||
|
|
||||||
|
internal SpelunkSignInException? Refusal { get; set; }
|
||||||
|
|
||||||
|
public Task<SpelunkSignInRecord> SignInAsync(string apiBase, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SignedIn.Add(apiBase);
|
||||||
|
|
||||||
|
return Refusal is { } refusal
|
||||||
|
? Task.FromException<SpelunkSignInRecord>(refusal)
|
||||||
|
: Task.FromResult(new SpelunkSignInRecord(
|
||||||
|
apiBase, SpelunkToken, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task SignOutAsync(SpelunkSignInRecord signIn, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
SignedOut.Add(signIn.Token);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -228,9 +228,10 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
// the wall clock.
|
// the wall clock.
|
||||||
reachability: reachability,
|
reachability: reachability,
|
||||||
|
|
||||||
// Scripted for the same reason: a Spelunk host's connect never leaves the process. See
|
// Scripted for the same reason: a Spelunk host's connect never leaves the process, and signing in
|
||||||
// ShellFlowTests.Spelunk.cs.
|
// to Spelunk opens no browser. See ShellFlowTests.Spelunk.cs.
|
||||||
spelunk: spelunkTunnels);
|
spelunk: spelunkTunnels,
|
||||||
|
spelunkAccounts: spelunkAccounts);
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -1040,19 +1041,17 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The one connection this application makes to a machine that is not in the keychain. What is worth
|
/// The one connection this application makes to a machine that is not in the keychain. What is worth
|
||||||
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
|
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
|
||||||
/// the port all come out of the one box.
|
/// the port all come out of the one line, and the spaces a paste brings with it are not part of any of
|
||||||
|
/// them.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
|
public async Task ThePrompt_DialsATypedAddressExactlyAsItWasTyped()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = " deploy@build.internal:2222 ";
|
await ConnectAtThePromptAsync(" deploy@build.internal:2222 ", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
request.Host.ShouldBe("build.internal");
|
request.Host.ShouldBe("build.internal");
|
||||||
@@ -1065,71 +1064,55 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AManualTargetWithNoPort_TakesTwentyTwo()
|
public async Task ThePrompt_DialsAnAddressWithNoPortOnTwentyTwo()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = "root@box";
|
await ConnectAtThePromptAsync("root@box", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
|
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
|
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
|
||||||
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
|
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
|
||||||
/// rather than here as a sentence about the box that was typed into.
|
/// rather than here as a sentence about the line that was typed into.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The first two have no <c>@</c>, so the prompt reads them as the name of a saved host rather than as an
|
||||||
|
/// address — which is why their sentences are the prompt's own and not the address parser's.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("", "Type a machine")]
|
[InlineData("", "Type the name of a saved host")]
|
||||||
[InlineData("build.internal", "Say who to log in as")]
|
[InlineData("build.internal", "No saved host is called")]
|
||||||
[InlineData("deploy@", "Say who to log in as")]
|
[InlineData("deploy@", "Say who to log in as")]
|
||||||
[InlineData("@build.internal", "Say who to log in as")]
|
[InlineData("@build.internal", "Say who to log in as")]
|
||||||
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
|
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
|
||||||
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
|
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
|
||||||
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
|
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
|
||||||
public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled(
|
public async Task ThePrompt_RefusesWhatCannotBeRead_BeforeAnythingIsDialled(
|
||||||
string typed,
|
string typed,
|
||||||
string because)
|
string because)
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = typed;
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
vault.ManualPassword = "hunter2";
|
shell.PromptText = typed;
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vault.ManualStatus.ShouldContain(because);
|
shell.PromptRefusal.ShouldContain(because);
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeFalse("a line that cannot be read has no password to ask for");
|
||||||
ssh.Requests.ShouldBeEmpty();
|
ssh.Requests.ShouldBeEmpty();
|
||||||
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
|
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// A password is the only thing this path can authenticate with, so an empty one is refused here rather
|
|
||||||
/// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
|
|
||||||
/// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
||||||
|
|
||||||
vault.ManualTarget = "root@box";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.ManualStatus.ShouldContain("password");
|
|
||||||
ssh.Requests.ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
|
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
|
||||||
/// and for nothing else. A plain search must not grow one on every keystroke.
|
/// and for nothing else. A plain search must not grow one on every keystroke.
|
||||||
@@ -1188,8 +1171,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the
|
/// An empty password is refused under the box rather than sent, as the connect prompt refuses one — and
|
||||||
/// address stays where it was typed, so filling the password in is all that is left to do.
|
/// the address stays where it was typed, so filling the password in is all that is left to do.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
|
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
|
||||||
@@ -1223,6 +1206,254 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
|
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The empty terminal's prompt ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Pressing Terminal with nothing open shows the prompt, and a tab opening takes it away: the two share the
|
||||||
|
/// renderer's rectangle, and the prompt is only allowed in it because they are exclusive.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePrompt_ShowsOnTheTerminalWithNothingOpen_AndGoesWhenATabOpens()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
shell.IsConnectPromptShowing.ShouldBeFalse("the hosts screen is showing, not the terminal");
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.IsConnectPromptShowing.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.PromptText = "deploy@build.internal";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
shell.PromptPassword = "hunter2";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.HasTabs.ShouldBeTrue();
|
||||||
|
shell.IsConnectPromptShowing.ShouldBeFalse("the session's tab has the rectangle now");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A saved host by name — case aside, as a person types it — whose binding wants a typed password: the
|
||||||
|
/// prompt asks for it in ssh's own words and dials the host with it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePrompt_ConnectsToASavedHostByName_AskingForItsPasswordFirst()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "PROD-DB";
|
||||||
|
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
ssh.Requests.ShouldBeEmpty("nothing is dialled before the password is typed");
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
|
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
|
||||||
|
|
||||||
|
shell.PromptPassword = "s3cret";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
ssh.Requests.ShouldHaveSingleItem().Credential
|
||||||
|
.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("s3cret");
|
||||||
|
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||||
|
|
||||||
|
shell.PromptText.ShouldBeEmpty();
|
||||||
|
shell.PromptPassword.ShouldBeEmpty("the password does not wait in the box");
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <b>The failure the prompt could most easily cause.</b> The drawer's remember-this tick is read when a
|
||||||
|
/// connection succeeds, and the prompt goes through the same connect path — so without the attempt
|
||||||
|
/// carrying its own answer, a tick left on in a drawer that is not even open would save a password typed
|
||||||
|
/// at a prompt that never offered to save anything.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePrompt_NeverSavesAPassword_EvenWithTheDrawersTickLeftOn()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
vault.RemembersConnectPassword = true;
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "prod-db";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
shell.PromptPassword = "s3cret";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
ssh.Requests.ShouldHaveSingleItem();
|
||||||
|
shell.HasTabs.ShouldBeTrue("the connection succeeded, which is when remembering would happen");
|
||||||
|
|
||||||
|
vault.Credentials.ShouldBeEmpty();
|
||||||
|
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An address the keychain does not hold is dialled as typed, with the typed password, and the keychain
|
||||||
|
/// is left exactly as it was.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePrompt_ConnectsToATypedAddress_AndSavesNothing()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "deploy@build.internal:2222";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
||||||
|
|
||||||
|
shell.PromptPassword = "hunter2";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
|
request.Host.ShouldBe("build.internal");
|
||||||
|
request.Port.ShouldBe(2222);
|
||||||
|
request.Username.ShouldBe("deploy");
|
||||||
|
|
||||||
|
vault.Hosts.ShouldHaveSingleItem("connecting without saving saves nothing");
|
||||||
|
vault.Credentials.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Enter on a name is a decision rather than a search, so a partial name is refused — but the refusal
|
||||||
|
/// names the nearest host, and Tab takes it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePrompt_RefusesAPartialName_AndTabCompletesIt()
|
||||||
|
{
|
||||||
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "prod";
|
||||||
|
|
||||||
|
shell.PromptMatches.ShouldHaveSingleItem().Label.ShouldBe("prod-db");
|
||||||
|
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.PromptRefusal.ShouldContain("Tab");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
|
||||||
|
shell.CompletePrompt().ShouldBeTrue();
|
||||||
|
shell.PromptText.ShouldBe("prod-db");
|
||||||
|
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A tap on a match takes that host outright — the phone's way of completing a name, where there is no
|
||||||
|
/// Tab — and carries on as Enter would: here, to the host's password, with nothing dialled yet.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ATappedMatchOnThePrompt_TakesThatHost()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "prod";
|
||||||
|
|
||||||
|
await shell.ChoosePromptMatchCommand.ExecuteAsync(shell.PromptMatches.ShouldHaveSingleItem());
|
||||||
|
|
||||||
|
shell.PromptText.ShouldBe("prod-db");
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
|
shell.ShowsPromptMatches.ShouldBeFalse("the matches are for the name, not drawn under its password");
|
||||||
|
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Two hosts in two vaults can share a name, and the prompt has no room to say which vault each is in, so
|
||||||
|
/// it refuses rather than guessing and points at the palette, which does say.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePrompt_RefusesANameTwoHostsShare()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await AddHostAsync(vault, "prod-db");
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "prod-db";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.PromptRefusal.ShouldContain("More than one");
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Escape walks back one line at a time, as it does in the palette: off the password onto the name with
|
||||||
|
/// the name intact, and then the name cleared.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task EscapeAtThePrompt_StepsBackALineAtATime()
|
||||||
|
{
|
||||||
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "root@box";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
shell.PromptPassword = "hunter2";
|
||||||
|
|
||||||
|
shell.CancelPromptCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeFalse();
|
||||||
|
shell.PromptText.ShouldBe("root@box");
|
||||||
|
shell.PromptPassword.ShouldBeEmpty();
|
||||||
|
|
||||||
|
shell.CancelPromptCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.PromptText.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// An empty password is refused on the prompt rather than sent: the server's "authentication failed" would
|
||||||
|
/// be a sentence about the machine when the problem is the box. A password is the only thing a typed
|
||||||
|
/// address can authenticate with — offering the keychain's keys would be a second binding resolution
|
||||||
|
/// beside the connect path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only
|
||||||
|
/// copy of.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ThePrompt_RefusesAnEmptyPassword()
|
||||||
|
{
|
||||||
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "root@box";
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.PromptRefusal.ShouldContain("password");
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A recent connection that names no keychain host is the address that was dialled, so choosing it asks
|
||||||
|
/// for that address's password — the log never held the password, which is the point of the path.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ARecentAddressOnThePrompt_AsksForItsPassword()
|
||||||
|
{
|
||||||
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
|
||||||
|
await shell.ChoosePromptRecentCommand.ExecuteAsync(
|
||||||
|
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
|
||||||
|
|
||||||
|
shell.PromptText.ShouldBe("deploy@build.internal:2222");
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
|
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
||||||
|
shell.PromptPassword.ShouldBeEmpty("the log never held one");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
|
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
|
||||||
/// away, or the next machine typed would be dialled with somebody else's password.
|
/// away, or the next machine typed would be dialled with somebody else's password.
|
||||||
@@ -1242,7 +1473,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
|
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
|
||||||
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a
|
/// the only way to connect; with a typed address it would answer "do you trust this key" by dialling a
|
||||||
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
|
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
|
||||||
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
|
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
|
||||||
/// the assertion would still catch it.
|
/// the assertion would still catch it.
|
||||||
@@ -1259,10 +1490,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
ssh.Failure = new SshHostKeyUnknownException(
|
ssh.Failure = new SshHostKeyUnknownException(
|
||||||
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@build.internal:2222";
|
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.HasPendingHostKey.ShouldBeTrue();
|
vault.HasPendingHostKey.ShouldBeTrue();
|
||||||
|
|
||||||
@@ -1277,46 +1505,25 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The
|
/// A recent row is one of two different things, and choosing it has to lead to whichever one it is. The
|
||||||
/// keychain half goes to the host's own connect bar rather than connecting from here, because that bar
|
/// keychain half takes the host as typing its name would, so its key, its saved password and its
|
||||||
/// is where its key, its password box and its refusals already live.
|
/// refusals are the keychain's — here, a host with nothing saved, so the prompt asks for its password
|
||||||
|
/// with nothing dialled yet.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
|
public async Task ARecentHostOnThePrompt_TakesThatHost()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
var host = vault.Hosts[0];
|
var host = vault.Hosts[0];
|
||||||
vault.SelectedHost = null;
|
|
||||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
||||||
|
|
||||||
shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));
|
|
||||||
|
|
||||||
shell.IsHostsShowing.ShouldBeTrue();
|
|
||||||
vault.SelectedHost.ShouldBe(host);
|
|
||||||
vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
|
|
||||||
/// so it goes straight back in — without the password, which was never stored and whose absence is the
|
|
||||||
/// point of that path rather than a gap in it.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
// Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
|
|
||||||
// screen the application happens to open on.
|
|
||||||
shell.ShowTerminalCommand.Execute(null);
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
|
||||||
shell.ConnectToRecentCommand.Execute(
|
await shell.ChoosePromptRecentCommand.ExecuteAsync(Recent("prod-db", "root@prod-db:22", host.EntityId));
|
||||||
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
|
|
||||||
|
|
||||||
vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
|
shell.PromptText.ShouldBe("prod-db", "a keychain host is taken by name, not as the address the log kept");
|
||||||
vault.ManualPassword.ShouldBeEmpty();
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
|
shell.PromptPasswordLabel.ShouldBe($"{host.Address}'s password:");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1324,17 +1531,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
/// about it, so the address is the honest answer rather than a tap that does nothing.
|
/// about it, so the address is the honest answer rather than a tap that does nothing.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
|
public async Task ARecentHostThatHasGone_FallsBackToItsAddressOnThePrompt()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
shell.ShowTerminalCommand.Execute(null);
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
|
||||||
shell.ConnectToRecentCommand.Execute(
|
await shell.ChoosePromptRecentCommand.ExecuteAsync(
|
||||||
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
|
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
|
||||||
|
|
||||||
vault.ManualTarget.ShouldBe("root@prod-db:22");
|
shell.PromptText.ShouldBe("root@prod-db:22");
|
||||||
shell.IsTerminalSurface.ShouldBeTrue();
|
shell.PromptPasswordLabel.ShouldBe("root@prod-db:22's password:");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One row of the connection log, built by hand.</summary>
|
/// <summary>One row of the connection log, built by hand.</summary>
|
||||||
@@ -3356,8 +3564,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
|
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
|
||||||
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
|
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
|
||||||
/// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet —
|
/// connect prompt is on the terminal surface, so staying there is what keeps it behind the sheet — and
|
||||||
/// and what the user comes back to whichever way they answer.
|
/// what the user comes back to whichever way they answer.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
|
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
|
||||||
@@ -3366,19 +3574,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
shell.ShowTerminalCommand.Execute(null);
|
|
||||||
|
|
||||||
ssh.Failure = new SshHostKeyUnknownException(
|
ssh.Failure = new SshHostKeyUnknownException(
|
||||||
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@build.internal:2222";
|
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.HasPendingHostKey.ShouldBeTrue();
|
vault.HasPendingHostKey.ShouldBeTrue();
|
||||||
|
|
||||||
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
|
shell.IsTerminalSurface.ShouldBeTrue("the connect prompt is on this surface and has not been taken away");
|
||||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8349,14 +8552,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@build.internal:2222";
|
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
shell.IsSessionConnected.ShouldBeTrue();
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||||
@@ -9466,7 +9666,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
TimeProvider.System,
|
TimeProvider.System,
|
||||||
ssh,
|
ssh,
|
||||||
CheapProfile,
|
CheapProfile,
|
||||||
resume);
|
resume,
|
||||||
|
|
||||||
|
// The same stand-in as the first launch's, so a relaunch neither opens a browser at Spelunk nor
|
||||||
|
// sends a sign-out anywhere real.
|
||||||
|
spelunkAccounts: spelunkAccounts);
|
||||||
|
|
||||||
private async Task SignedInAsync()
|
private async Task SignedInAsync()
|
||||||
{
|
{
|
||||||
@@ -11156,6 +11360,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
return vault;
|
return vault;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Types an address at the connect prompt, then its password, pressing Enter after each.</summary>
|
||||||
|
private async Task ConnectAtThePromptAsync(string typed, string password)
|
||||||
|
{
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.PromptText = typed;
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.PromptPassword = password;
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
|
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
|
||||||
private async Task<FakeSftpSession> ConnectedTransfersAsync()
|
private async Task<FakeSftpSession> ConnectedTransfersAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,6 +80,32 @@ public sealed class UpdateFlowTests : IDisposable
|
|||||||
Build().CurrentVersion.ShouldBe("0.4.2");
|
Build().CurrentVersion.ShouldBe("0.4.2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABuildThatCannotReplaceItself_ForAReasonOfItsOwn_SaysThatInstead()
|
||||||
|
{
|
||||||
|
var updates = new UpdateViewModel(
|
||||||
|
new UnavailableUpdateChannel("Update it with the package manager that installed it."),
|
||||||
|
new ClientSettingsStore(Paths),
|
||||||
|
TimeProvider.System,
|
||||||
|
() => 0,
|
||||||
|
restart: _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
updates.IsUnsupported.ShouldBeTrue();
|
||||||
|
updates.UnsupportedReason.ShouldBe("Update it with the package manager that installed it.");
|
||||||
|
updates.IsUnsupportedForTheUsualReason.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABuildThatCannotReplaceItself_ForTheUsualReasons_LeavesTheScreenItsOwnSentence()
|
||||||
|
{
|
||||||
|
channel.IsSupported = false;
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
updates.UnsupportedReason.ShouldBeNull();
|
||||||
|
updates.IsUnsupportedForTheUsualReason.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
|
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,18 +39,14 @@ internal static class HostFactory
|
|||||||
BindPort = 1080,
|
BindPort = 1080,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>A credential holding a Spelunk API key, for the hosts reached through Spelunk.</summary>
|
|
||||||
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
|
||||||
|
|
||||||
/// <summary>A server's id in Spelunk.</summary>
|
/// <summary>A server's id in Spelunk.</summary>
|
||||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/> — signing in through Spelunk
|
/// A route to <see cref="SpelunkServer"/> — signing in through Spelunk when <paramref name="signIn"/> says so.
|
||||||
/// when <paramref name="signIn"/> says so.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) =>
|
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) =>
|
||||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey, SignInThroughSpelunk = signIn };
|
new() { ApiBase = apiBase, ServerId = SpelunkServer, SignInThroughSpelunk = signIn };
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.Domain.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A Spelunk key: an API key and the one Spelunk it may be sent to.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// What these pin is that the pair is stored as it will be compared — the address in its one normal form —
|
|
||||||
/// that an address the key could leak across a network from is refused, and that a merge never pairs one
|
|
||||||
/// device's key with another device's address.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class SpelunkKeySecretTests
|
|
||||||
{
|
|
||||||
private const string Api = "https://api.spelunk.example.com";
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AKey_RoundTrips()
|
|
||||||
{
|
|
||||||
var key = Key();
|
|
||||||
|
|
||||||
SpelunkKeySecretCodec.TryDecode(SpelunkKeySecretCodec.Encode(key), out var document).ShouldBeTrue();
|
|
||||||
|
|
||||||
document.ShouldNotBeNull();
|
|
||||||
document.Key.ShouldBe(key);
|
|
||||||
document.SchemaVersion.ShouldBe(SpelunkKeySecretCodec.CurrentSchemaVersion);
|
|
||||||
document.IsReadOnly.ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AKey_EncodesAtTheBaseVersion()
|
|
||||||
{
|
|
||||||
Encoding.UTF8.GetString(SpelunkKeySecretCodec.Encode(Key()))
|
|
||||||
.ShouldBe(
|
|
||||||
"""{"schemaVersion":1,"label":"Spelunk","apiBase":"https://api.spelunk.example.com","apiKey":"spelunk_one"}""");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("https://api.spelunk.example.com/")]
|
|
||||||
[InlineData("HTTPS://API.SPELUNK.EXAMPLE.COM")]
|
|
||||||
public void AnAddressNotInItsNormalForm_IsRefused(string apiBase)
|
|
||||||
{
|
|
||||||
// Stored the one way or not at all: it is compared byte for byte with what a host or a link names.
|
|
||||||
(Key() with { ApiBase = apiBase }).TryValidate(out var reason).ShouldBeFalse();
|
|
||||||
reason.ShouldNotBeNull().ShouldContain("normal form");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PlainHttpAcrossANetwork_IsRefused()
|
|
||||||
{
|
|
||||||
(Key() with { ApiBase = "http://api.spelunk.example.com" }).TryValidate(out var reason).ShouldBeFalse();
|
|
||||||
reason.ShouldNotBeNull().ShouldContain("https");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void PlainHttpToThisMachine_IsAllowed()
|
|
||||||
{
|
|
||||||
(Key() with { ApiBase = "http://localhost:5080" }).TryValidate(out _).ShouldBeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData("")]
|
|
||||||
[InlineData(" ")]
|
|
||||||
[InlineData("spelunk_one\r\nX-Other: header")]
|
|
||||||
public void AKeyThatIsNotAKey_IsRefused(string apiKey)
|
|
||||||
{
|
|
||||||
(Key() with { ApiKey = apiKey }).TryValidate(out _).ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void APayloadWithAnAddressOutOfNormalForm_DoesNotDecode()
|
|
||||||
{
|
|
||||||
// The address decides where the key may go, so a payload that disagrees with how every client
|
|
||||||
// writes it is refused rather than normalised into something nobody chose.
|
|
||||||
var payload = Encoding.UTF8.GetBytes(
|
|
||||||
"""{"schemaVersion":1,"label":"Spelunk","apiBase":"https://api.spelunk.example.com/","apiKey":"k"}""");
|
|
||||||
|
|
||||||
SpelunkKeySecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AKeyChangedOnOneSide_AndAnAddressOnTheOther_IsKeptAsAPair()
|
|
||||||
{
|
|
||||||
// The case this merge exists for: merging field by field would send the new key to the new address,
|
|
||||||
// a pairing neither device made.
|
|
||||||
var ancestor = Key();
|
|
||||||
var local = ancestor with { ApiKey = "spelunk_two" };
|
|
||||||
var remote = ancestor with { ApiBase = "https://spelunk.other.example" };
|
|
||||||
|
|
||||||
var result = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
|
|
||||||
|
|
||||||
(result.Merged.ApiBase, result.Merged.ApiKey).ShouldBeOneOf(
|
|
||||||
(local.ApiBase, local.ApiKey),
|
|
||||||
(remote.ApiBase, remote.ApiKey));
|
|
||||||
|
|
||||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
|
||||||
conflict.Field.ShouldBe(SpelunkKeySecretMerge.PairField);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AKeyReplacedOnBothSides_NeverReachesTheConflictLog()
|
|
||||||
{
|
|
||||||
var ancestor = Key();
|
|
||||||
var local = ancestor with { ApiKey = "spelunk_local_secret" };
|
|
||||||
var remote = ancestor with { ApiKey = "spelunk_remote_secret" };
|
|
||||||
|
|
||||||
var result = SpelunkKeySecretMerge.Merge(ancestor, local, remote);
|
|
||||||
|
|
||||||
var conflict = result.Conflicts.ShouldHaveSingleItem();
|
|
||||||
$"{conflict.Kept} {conflict.Discarded}".ShouldNotContain("secret");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AKeyChangedOnOneSideOnly_IsTakenWithoutAConflict()
|
|
||||||
{
|
|
||||||
var ancestor = Key();
|
|
||||||
var local = ancestor with { ApiKey = "spelunk_two", Label = "renamed" };
|
|
||||||
|
|
||||||
var result = SpelunkKeySecretMerge.Merge(ancestor, local, ancestor);
|
|
||||||
|
|
||||||
result.Merged.ShouldBe(local);
|
|
||||||
result.HasConflicts.ShouldBeFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static SpelunkKeySecret Key() => new()
|
|
||||||
{
|
|
||||||
Label = "Spelunk",
|
|
||||||
ApiBase = Api,
|
|
||||||
ApiKey = "spelunk_one",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -73,14 +73,12 @@ public sealed class SpelunkRouteTests
|
|||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("server")]
|
[InlineData("server")]
|
||||||
[InlineData("key")]
|
|
||||||
[InlineData("api")]
|
[InlineData("api")]
|
||||||
public void ARouteMissingAPart_IsRefused(string missing)
|
public void ARouteMissingAPart_IsRefused(string missing)
|
||||||
{
|
{
|
||||||
var route = missing switch
|
var route = missing switch
|
||||||
{
|
{
|
||||||
"server" => ThroughSpelunk() with { ServerId = Guid.Empty },
|
"server" => ThroughSpelunk() with { ServerId = Guid.Empty },
|
||||||
"key" => ThroughSpelunk() with { ApiKeyCredentialId = Guid.Empty },
|
|
||||||
_ => ThroughSpelunk() with { ApiBase = "https://api.spelunk.example.com/" },
|
_ => ThroughSpelunk() with { ApiBase = "https://api.spelunk.example.com/" },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -98,7 +96,7 @@ public sealed class SpelunkRouteTests
|
|||||||
|
|
||||||
document.ShouldNotBeNull();
|
document.ShouldNotBeNull();
|
||||||
document.Host.ShouldBe(host);
|
document.Host.ShouldBe(host);
|
||||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignedInDeviceSchemaVersion);
|
||||||
document.IsReadOnly.ShouldBeFalse();
|
document.IsReadOnly.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +120,7 @@ public sealed class SpelunkRouteTests
|
|||||||
|
|
||||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||||
"""
|
"""
|
||||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
|
{"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d"}}
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,10 +154,10 @@ public sealed class SpelunkRouteTests
|
|||||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||||
{
|
{
|
||||||
// The rule every version before this one relies on, checked from the far side: a build that knows
|
// The rule every version before this one relies on, checked from the far side: a build that knows
|
||||||
// version 10 reads a version 11 host, shows it, and refuses to re-encode what it cannot represent.
|
// version 11 reads a version 12 host, shows it, and refuses to re-encode what it cannot represent.
|
||||||
var payload = Encoding.UTF8.GetBytes(
|
var payload = Encoding.UTF8.GetBytes(
|
||||||
"""
|
"""
|
||||||
{"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
{"schemaVersion":12,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"},"somethingNewer":true}
|
||||||
""");
|
""");
|
||||||
|
|
||||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||||
@@ -207,7 +205,7 @@ public sealed class SpelunkRouteTests
|
|||||||
document.ShouldNotBeNull();
|
document.ShouldNotBeNull();
|
||||||
document.Host.ShouldBe(host);
|
document.Host.ShouldBe(host);
|
||||||
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
|
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
|
||||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion);
|
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignedInDeviceSchemaVersion);
|
||||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||||
document.IsReadOnly.ShouldBeFalse();
|
document.IsReadOnly.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
@@ -219,13 +217,15 @@ public sealed class SpelunkRouteTests
|
|||||||
|
|
||||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||||
"""
|
"""
|
||||||
{"schemaVersion":10,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c","signIn":true}}
|
{"schemaVersion":11,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","signIn":true}}
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable()
|
public void ARouteWrittenWithAnApiKey_DecodesWithoutIt_AsNotSigningIn_AndStaysEditable()
|
||||||
{
|
{
|
||||||
|
// The API key a route used to name is an unknown member now, skipped: the host is reached as whoever
|
||||||
|
// this device signed in to Spelunk as, and saving it writes it at version 11 without the key.
|
||||||
var payload = Encoding.UTF8.GetBytes(
|
var payload = Encoding.UTF8.GetBytes(
|
||||||
"""
|
"""
|
||||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
|
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d","apiKeyCredentialId":"0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c"}}
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DodoSSH.Client.Auth;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Session.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Signing this device in to Spelunk, with the browser and the redirect stood in for and Spelunk's token
|
||||||
|
/// endpoint answered in-process: what is opened, what is checked when the browser comes back, what is sent for
|
||||||
|
/// the token, and what is refused.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpelunkAccountsTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string ApiBase = "https://api.spelunk.example.com";
|
||||||
|
|
||||||
|
private const string IssuedToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||||
|
|
||||||
|
private static readonly Guid SessionId = Guid.Parse("0192f0c8-eeee-7c3d-8e4f-5a6b7c8d9e0e");
|
||||||
|
|
||||||
|
private readonly ScriptedBrowser browser = new();
|
||||||
|
|
||||||
|
private readonly TokenEndpoint spelunk = new();
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
public void Dispose() => spelunk.Dispose();
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ASignIn_OpensSpelunksAuthorize_WithPkceAndThisDevice_AndRedeemsTheCode()
|
||||||
|
{
|
||||||
|
var signIn = await Accounts().SignInAsync(ApiBase + "/", Token);
|
||||||
|
|
||||||
|
var opened = browser.Opened.ShouldHaveSingleItem();
|
||||||
|
opened.GetLeftPart(UriPartial.Path).ShouldBe($"{ApiBase}/api/app-sign-in/authorize");
|
||||||
|
var query = Query(opened);
|
||||||
|
query["client_id"].ShouldBe("dodossh");
|
||||||
|
query["response_type"].ShouldBe("code");
|
||||||
|
query["code_challenge_method"].ShouldBe("S256");
|
||||||
|
query["device_name"].ShouldBe("work laptop");
|
||||||
|
query["redirect_uri"].ShouldBe(ScriptedCallback.Redirect);
|
||||||
|
|
||||||
|
// The verifier sent for the token is the one the challenge was made from.
|
||||||
|
using var sent = JsonDocument.Parse(spelunk.Bodies.ShouldHaveSingleItem());
|
||||||
|
var verifier = sent.RootElement.GetProperty("codeVerifier").GetString()!;
|
||||||
|
Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))).ShouldBe(query["code_challenge"]);
|
||||||
|
sent.RootElement.GetProperty("code").GetString().ShouldBe("the-code");
|
||||||
|
sent.RootElement.GetProperty("redirectUri").GetString().ShouldBe(ScriptedCallback.Redirect);
|
||||||
|
sent.RootElement.GetProperty("clientId").GetString().ShouldBe("dodossh");
|
||||||
|
|
||||||
|
signIn.ApiBase.ShouldBe(ApiBase);
|
||||||
|
signIn.Token.ShouldBe(IssuedToken);
|
||||||
|
signIn.SessionId.ShouldBe(SessionId);
|
||||||
|
signIn.Email.ShouldBe("ops@example.com");
|
||||||
|
signIn.ToString().ShouldNotContain(IssuedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAnswerToSomeOtherSignIn_IsIgnored()
|
||||||
|
{
|
||||||
|
browser.Answer = (_, _) => new Dictionary<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = "not-this-one" };
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("different sign-in");
|
||||||
|
spelunk.Bodies.ShouldBeEmpty("a code from another sign-in is never redeemed");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SayingNoInTheBrowser_IsSaidAsACancel()
|
||||||
|
{
|
||||||
|
browser.Answer = (_, state) => new Dictionary<string, string>(StringComparer.Ordinal) { ["error"] = "access_denied", ["state"] = state };
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("cancelled in the browser");
|
||||||
|
spelunk.Bodies.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ARefusedCode_IsSaid()
|
||||||
|
{
|
||||||
|
spelunk.Status = HttpStatusCode.BadRequest;
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("did not accept the sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ARefusedCode_IsSaidInSpelunksOwnWords()
|
||||||
|
{
|
||||||
|
spelunk.Status = HttpStatusCode.BadRequest;
|
||||||
|
spelunk.ProblemDetail = "The code is wrong, used or expired. Sign in again from the app.";
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("used or expired");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ATokenThatIsNotAnAppSignIns_IsRefused()
|
||||||
|
{
|
||||||
|
// Spelunk reads a bearer token by its prefix: without this one it is an API key or a JWT to it, and an
|
||||||
|
// answer carrying one is not Spelunk's app sign-in.
|
||||||
|
spelunk.IssuedToken = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("does not understand");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ABrowserThatWillNotOpen_IsSaid()
|
||||||
|
{
|
||||||
|
browser.Failure = new InvalidOperationException("No browser is installed.");
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("No browser is installed.");
|
||||||
|
spelunk.Bodies.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APhoneBrowserClosedWithoutAnAnswer_IsSaid_AsTheReasonThePhoneGave()
|
||||||
|
{
|
||||||
|
// What AndroidRedirectCallback.Abandon fails the wait with when the app comes back with no redirect.
|
||||||
|
browser.Callback.Failure = new OidcException("Sign-in was cancelled: the browser closed without signing you in.");
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("browser closed without signing you in");
|
||||||
|
spelunk.Bodies.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ABrowserThatNeverComesBack_TimesOut_AndSaysSo()
|
||||||
|
{
|
||||||
|
browser.Callback.Never = true;
|
||||||
|
|
||||||
|
var accounts = new SpelunkAccounts(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop")
|
||||||
|
{
|
||||||
|
BrowserTimeout = TimeSpan.FromMilliseconds(50),
|
||||||
|
};
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => accounts.SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("in time");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingTheDialog_CancelsTheSignIn_AndRedeemsNothing()
|
||||||
|
{
|
||||||
|
browser.Callback.Never = true;
|
||||||
|
using var closed = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||||
|
closed.CancelAfter(TimeSpan.FromMilliseconds(50));
|
||||||
|
|
||||||
|
await Should.ThrowAsync<OperationCanceledException>(() => Accounts().SignInAsync(ApiBase, closed.Token));
|
||||||
|
|
||||||
|
spelunk.Bodies.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task APhonesRedirect_IsSentExactlyAsSpelunkAllowsIt_BothTimes()
|
||||||
|
{
|
||||||
|
// Spelunk matches an app-scheme redirect byte for byte, at authorize and again at the token request.
|
||||||
|
browser.Callback.RedirectUri = new Uri("dev.dodotech.dodossh:/callback");
|
||||||
|
|
||||||
|
await Accounts().SignInAsync(ApiBase, Token);
|
||||||
|
|
||||||
|
Query(browser.Opened.ShouldHaveSingleItem())["redirect_uri"].ShouldBe("dev.dodotech.dodossh:/callback");
|
||||||
|
using var sent = JsonDocument.Parse(spelunk.Bodies.ShouldHaveSingleItem());
|
||||||
|
sent.RootElement.GetProperty("redirectUri").GetString().ShouldBe("dev.dodotech.dodossh:/callback");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnADesktop_TheBrowserComesBackToALoopbackLiteral_AndTheListenerTakesOneAnswer()
|
||||||
|
{
|
||||||
|
// The real listener, and a "browser" that follows Spelunk's redirect the way a browser would. Spelunk
|
||||||
|
// allows plain HTTP to a loopback IP literal only - never localhost - on any port.
|
||||||
|
var following = new FollowingBrowser();
|
||||||
|
var accounts = new SpelunkAccounts(new HttpClient(spelunk), following, deviceName: "work laptop");
|
||||||
|
|
||||||
|
var signIn = await accounts.SignInAsync(ApiBase, Token);
|
||||||
|
|
||||||
|
var redirect = new Uri(Query(following.Opened.ShouldHaveSingleItem())["redirect_uri"]);
|
||||||
|
redirect.Scheme.ShouldBe("http");
|
||||||
|
redirect.Host.ShouldBe("127.0.0.1");
|
||||||
|
redirect.AbsolutePath.ShouldBe("/callback");
|
||||||
|
(await following.Page).ShouldContain("return to DodoSSH");
|
||||||
|
signIn.Token.ShouldBe(IssuedToken);
|
||||||
|
|
||||||
|
// Closed once it has its answer: nothing is listening there any more.
|
||||||
|
await Should.ThrowAsync<HttpRequestException>(() => following.Http.GetAsync(redirect, Token));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ATokenThatCouldNotBeAHeader_IsRefused()
|
||||||
|
{
|
||||||
|
spelunk.IssuedToken = "spelunkapp_with a space";
|
||||||
|
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(() => Accounts().SignInAsync(ApiBase, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("does not understand");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnAddressThatWouldSendTheSignInInClear_OpensNoBrowser()
|
||||||
|
{
|
||||||
|
var refusal = await Should.ThrowAsync<SpelunkSignInException>(
|
||||||
|
() => Accounts().SignInAsync("http://api.spelunk.example.com", Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain("https");
|
||||||
|
browser.Opened.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SigningOut_TellsSpelunk_WithTheToken_AndNeverThrows()
|
||||||
|
{
|
||||||
|
var accounts = Accounts();
|
||||||
|
var signIn = await accounts.SignInAsync(ApiBase, Token);
|
||||||
|
|
||||||
|
await accounts.SignOutAsync(signIn, Token);
|
||||||
|
|
||||||
|
var (method, uri, authorization) = spelunk.Others.ShouldHaveSingleItem();
|
||||||
|
method.ShouldBe(HttpMethod.Delete);
|
||||||
|
uri.ShouldBe(new Uri($"{ApiBase}/api/app-sign-in/session"));
|
||||||
|
authorization.ShouldBe($"Bearer {IssuedToken}");
|
||||||
|
|
||||||
|
spelunk.Unreachable = true;
|
||||||
|
await accounts.SignOutAsync(signIn, Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SigningOut_SendsTheTokenOnlyToTheAddressItCameFrom()
|
||||||
|
{
|
||||||
|
var accounts = Accounts();
|
||||||
|
var signIn = await accounts.SignInAsync(ApiBase, Token);
|
||||||
|
|
||||||
|
// A damaged record whose address is no longer one a sign-in could have come from is forgotten unsent.
|
||||||
|
await accounts.SignOutAsync(signIn with { ApiBase = "http://spelunk.example.com" }, Token);
|
||||||
|
|
||||||
|
spelunk.Others.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpelunkAccounts Accounts() =>
|
||||||
|
new(new HttpClient(spelunk), browser, _ => browser.Callback, TimeProvider.System, "work laptop");
|
||||||
|
|
||||||
|
private static Dictionary<string, string> Query(Uri uri) =>
|
||||||
|
uri.Query.TrimStart('?').Split('&')
|
||||||
|
.Select(pair => pair.Split('=', 2))
|
||||||
|
.ToDictionary(pair => pair[0], pair => Uri.UnescapeDataString(pair[1]), StringComparer.Ordinal);
|
||||||
|
|
||||||
|
private static string Base64Url(byte[] bytes) =>
|
||||||
|
Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_');
|
||||||
|
|
||||||
|
/// <summary>A browser that, opened at the authorize URL, comes straight back with what the test scripts.</summary>
|
||||||
|
private sealed class ScriptedBrowser : IBrowserLauncher
|
||||||
|
{
|
||||||
|
internal List<Uri> Opened { get; } = [];
|
||||||
|
|
||||||
|
internal ScriptedCallback Callback { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>The answer, from the authorize URL and the state it carried. A code and that state by default.</summary>
|
||||||
|
internal Func<Uri, string, Dictionary<string, string>> Answer { get; set; } =
|
||||||
|
(_, state) => new Dictionary<string, string>(StringComparer.Ordinal) { ["code"] = "the-code", ["state"] = state };
|
||||||
|
|
||||||
|
/// <summary>What opening the browser throws, or null when it opens.</summary>
|
||||||
|
internal Exception? Failure { get; set; }
|
||||||
|
|
||||||
|
public Task OpenAsync(Uri url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Failure is { } failure)
|
||||||
|
{
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
Opened.Add(url);
|
||||||
|
Callback.Result = new CallbackResult(Answer(url, Query(url)["state"]));
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ScriptedCallback : IAuthorizationCallback
|
||||||
|
{
|
||||||
|
internal const string Redirect = "http://127.0.0.1:53682/callback";
|
||||||
|
|
||||||
|
internal CallbackResult? Result { get; set; }
|
||||||
|
|
||||||
|
/// <summary>What the wait fails with, or null when it answers.</summary>
|
||||||
|
internal Exception? Failure { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Whether the browser never comes back, so only a cancellation ends the wait.</summary>
|
||||||
|
internal bool Never { get; set; }
|
||||||
|
|
||||||
|
public Uri RedirectUri { get; set; } = new(Redirect);
|
||||||
|
|
||||||
|
public async Task<CallbackResult> WaitForCallbackAsync(string completionHtml, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Never)
|
||||||
|
{
|
||||||
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Failure is { } failure
|
||||||
|
? throw failure
|
||||||
|
: Result ?? throw new InvalidOperationException("The browser was never opened.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A browser that does what Spelunk's consent page ends with: goes to the redirect URI with a code and the
|
||||||
|
/// state it was opened with.
|
||||||
|
/// </summary>
|
||||||
|
private sealed class FollowingBrowser : IBrowserLauncher
|
||||||
|
{
|
||||||
|
internal HttpClient Http { get; } = new() { Timeout = TimeSpan.FromSeconds(5) };
|
||||||
|
|
||||||
|
internal List<Uri> Opened { get; } = [];
|
||||||
|
|
||||||
|
/// <summary>The page the listener showed once the browser came back.</summary>
|
||||||
|
internal Task<string> Page { get; private set; } = Task.FromResult(string.Empty);
|
||||||
|
|
||||||
|
public async Task OpenAsync(Uri url, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Opened.Add(url);
|
||||||
|
var query = Query(url);
|
||||||
|
|
||||||
|
// Not awaited by the sign-in, as a real browser is not: it is answered once the listener is waiting.
|
||||||
|
var back = new Uri($"{query["redirect_uri"]}?code=the-code&state={Uri.EscapeDataString(query["state"])}");
|
||||||
|
Page = Task.Run(() => Http.GetStringAsync(back, cancellationToken), cancellationToken);
|
||||||
|
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Spelunk's token and sign-out endpoints.</summary>
|
||||||
|
private sealed class TokenEndpoint : HttpMessageHandler
|
||||||
|
{
|
||||||
|
internal List<string> Bodies { get; } = [];
|
||||||
|
|
||||||
|
internal List<(HttpMethod Method, Uri Uri, string? Authorization)> Others { get; } = [];
|
||||||
|
|
||||||
|
internal HttpStatusCode Status { get; set; } = HttpStatusCode.OK;
|
||||||
|
|
||||||
|
internal string IssuedToken { get; set; } = SpelunkAccountsTests.IssuedToken;
|
||||||
|
|
||||||
|
/// <summary>The detail of a refusal, as Spelunk's problem details carry it.</summary>
|
||||||
|
internal string? ProblemDetail { get; set; }
|
||||||
|
|
||||||
|
internal bool Unreachable { get; set; }
|
||||||
|
|
||||||
|
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (Unreachable)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException("Connection refused.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.Method != HttpMethod.Post)
|
||||||
|
{
|
||||||
|
Others.Add((request.Method, request.RequestUri!, request.Headers.Authorization?.ToString()));
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.NoContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
Bodies.Add(await request.Content!.ReadAsStringAsync(cancellationToken));
|
||||||
|
|
||||||
|
return new HttpResponseMessage(Status)
|
||||||
|
{
|
||||||
|
Content = new StringContent(
|
||||||
|
Status == HttpStatusCode.OK
|
||||||
|
? JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
token = IssuedToken,
|
||||||
|
sessionId = SessionId,
|
||||||
|
user = new { id = Guid.CreateVersion7(), email = "ops@example.com", displayName = "Ops" },
|
||||||
|
})
|
||||||
|
: JsonSerializer.Serialize(new { title = "Sign-in not accepted", detail = ProblemDetail }),
|
||||||
|
Encoding.UTF8,
|
||||||
|
"application/json"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ namespace DodoSSH.Client.Session.Tests;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class SpelunkTunnelTests : IAsyncLifetime
|
public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||||
{
|
{
|
||||||
private const string ApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
private const string SignInToken = "spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||||
|
|
||||||
private static readonly Guid ServerId = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
private static readonly Guid ServerId = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
var ticket = spelunk.Requests[0];
|
var ticket = spelunk.Requests[0];
|
||||||
ticket.Method.ShouldBe("POST");
|
ticket.Method.ShouldBe("POST");
|
||||||
ticket.Path.ShouldBe($"/api/servers/{ServerId}/tunnel-tickets");
|
ticket.Path.ShouldBe($"/api/servers/{ServerId}/tunnel-tickets");
|
||||||
ticket.Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
|
ticket.Headers["Authorization"].ShouldBe($"Bearer {SignInToken}");
|
||||||
ticket.Body.ShouldBe("""{"port":22}""");
|
ticket.Body.ShouldBe("""{"port":22}""");
|
||||||
|
|
||||||
var upgrade = spelunk.Requests[1];
|
var upgrade = spelunk.Requests[1];
|
||||||
@@ -98,7 +98,8 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData(HttpStatusCode.Unauthorized, "did not accept the API key")]
|
[InlineData(HttpStatusCode.Unauthorized, "signed this device out")]
|
||||||
|
[InlineData(HttpStatusCode.Forbidden, "not allowed to open a terminal")]
|
||||||
[InlineData(HttpStatusCode.NotFound, "does not know this server")]
|
[InlineData(HttpStatusCode.NotFound, "does not know this server")]
|
||||||
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
|
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
|
||||||
[InlineData(HttpStatusCode.InternalServerError, "HTTP 500")]
|
[InlineData(HttpStatusCode.InternalServerError, "HTTP 500")]
|
||||||
@@ -202,24 +203,18 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
|
public async Task ADamagedSignIn_IsRefusedBeforeAnythingIsSent_AsASignedOutDevice()
|
||||||
{
|
{
|
||||||
|
// It came from Spelunk and was checked then, so a token that is not a header value is a damaged cache,
|
||||||
|
// and the remedy is the one a 401 gets: sign in again.
|
||||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||||
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token));
|
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunkapp_abc\ndef", 22, signIn: null, Token));
|
||||||
|
|
||||||
refusal.Message.ShouldContain("does not hold one");
|
refusal.Message.ShouldContain("Sign in to Spelunk again");
|
||||||
|
refusal.StatusCode.ShouldBe(HttpStatusCode.Unauthorized);
|
||||||
spelunk.Requests.ShouldBeEmpty();
|
spelunk.Requests.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AKeyPastedWithANewline_IsTrimmed()
|
|
||||||
{
|
|
||||||
await using var route = await new SpelunkTunnel()
|
|
||||||
.OpenAsync(spelunk.ApiBase, ServerId, ApiKey + "\r\n", 22, signIn: null, Token);
|
|
||||||
|
|
||||||
spelunk.Requests[0].Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnUnreachableSpelunk_SaysWhere()
|
public async Task AnUnreachableSpelunk_SaysWhere()
|
||||||
{
|
{
|
||||||
@@ -230,7 +225,7 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
closed.Stop();
|
closed.Stop();
|
||||||
|
|
||||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||||
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token));
|
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, SignInToken, 22, signIn: null, Token));
|
||||||
|
|
||||||
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
|
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
|
||||||
}
|
}
|
||||||
@@ -389,14 +384,14 @@ public sealed class SpelunkTunnelTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
|
/// <summary>Opens a tunnel to the test server with the one sign-in and server the suite uses.</summary>
|
||||||
file static class Opening
|
file static class Opening
|
||||||
{
|
{
|
||||||
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
|
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
|
||||||
new SpelunkTunnel().OpenAsync(
|
new SpelunkTunnel().OpenAsync(
|
||||||
apiBase,
|
apiBase,
|
||||||
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||||
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
"spelunkapp_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||||
port,
|
port,
|
||||||
signIn,
|
signIn,
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
|
|||||||
@@ -211,6 +211,31 @@ public sealed class SftpSessionTests(SshServerFixture fixture)
|
|||||||
refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
refusal.Presentation.Fingerprint.ShouldStartWith("SHA256:");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AHostWhoseLoginPrintsText_FailsWithinTheConnectTimeout_AndSaysWhy()
|
||||||
|
{
|
||||||
|
var knownHosts = new InMemoryKnownHostStore();
|
||||||
|
var factory = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
|
var request = Request() with { Port = fixture.ChattyPort, ConnectTimeout = TimeSpan.FromSeconds(3) };
|
||||||
|
|
||||||
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
|
await factory.OpenSftpAsync(request, Token));
|
||||||
|
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||||
|
|
||||||
|
// The banner's first four bytes read as the length of a packet about a gigabyte long, and SSH.NET's
|
||||||
|
// own default is to wait for the rest of it for ever — which the file browser drew as "Connecting…"
|
||||||
|
// with no end. The outer bound is only there so that regression fails this test rather than hangs it,
|
||||||
|
// and it is a cancellation rather than a timeout so that it cannot be mistaken for the answer.
|
||||||
|
using var bound = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||||
|
bound.CancelAfter(TimeSpan.FromSeconds(30));
|
||||||
|
|
||||||
|
var failure = await Should.ThrowAsync<TimeoutException>(async () =>
|
||||||
|
await factory.OpenSftpAsync(request, Token).WaitAsync(bound.Token));
|
||||||
|
|
||||||
|
failure.Message.ShouldContain("SFTP service did not answer within 3 seconds");
|
||||||
|
}
|
||||||
|
|
||||||
private SshConnectionRequest Request() => new(
|
private SshConnectionRequest Request() => new(
|
||||||
fixture.Host,
|
fixture.Host,
|
||||||
fixture.Port,
|
fixture.Port,
|
||||||
|
|||||||
@@ -42,6 +42,17 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
|
|
||||||
private const int SshPort = 2222;
|
private const int SshPort = 2222;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A second port on the same sshd, where every session starts with a line of text before SFTP.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What a host with an <c>echo</c> in its login script looks like to a file-transfer client: the text
|
||||||
|
/// arrives where the server's first SFTP packet should be. A port rather than an account because this
|
||||||
|
/// image's sshd runs unprivileged and cannot log in as anybody but <see cref="Username"/>, and a port
|
||||||
|
/// rather than a change to the main one so the shell and SFTP suites keep a server that behaves.
|
||||||
|
/// </remarks>
|
||||||
|
private const int ChattySshPort = 2223;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How many connections in a row the server has to answer before this fixture calls it ready.
|
/// How many connections in a row the server has to answer before this fixture calls it ready.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -65,6 +76,9 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
/// <summary>Host port the container's sshd is published on.</summary>
|
/// <summary>Host port the container's sshd is published on.</summary>
|
||||||
public ushort Port => container!.GetMappedPublicPort(SshPort);
|
public ushort Port => container!.GetMappedPublicPort(SshPort);
|
||||||
|
|
||||||
|
/// <summary>Host port of the same sshd whose sessions print a banner first; see <see cref="ChattySshPort"/>.</summary>
|
||||||
|
public ushort ChattyPort => container!.GetMappedPublicPort(ChattySshPort);
|
||||||
|
|
||||||
/// <summary>Host the container is reachable at.</summary>
|
/// <summary>Host the container is reachable at.</summary>
|
||||||
public string Host => container!.Hostname;
|
public string Host => container!.Hostname;
|
||||||
|
|
||||||
@@ -83,6 +97,7 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
.WithEnvironment("SUDO_ACCESS", "false")
|
.WithEnvironment("SUDO_ACCESS", "false")
|
||||||
.WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
|
.WithEnvironment("PUBLIC_KEY", ExportOpenSshPublicKey(ClientKey))
|
||||||
.WithPortBinding(SshPort, assignRandomHostPort: true)
|
.WithPortBinding(SshPort, assignRandomHostPort: true)
|
||||||
|
.WithPortBinding(ChattySshPort, assignRandomHostPort: true)
|
||||||
|
|
||||||
// The entrypoint generates host keys, rewrites sshd_config and installs the authorized
|
// The entrypoint generates host keys, rewrites sshd_config and installs the authorized
|
||||||
// key before sshd is usable, so a published port is not readiness. Both conditions are
|
// key before sshd is usable, so a published port is not readiness. Both conditions are
|
||||||
@@ -176,6 +191,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
/// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version
|
/// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version
|
||||||
/// of this fixture did exactly that and failed twenty-eight tests.
|
/// of this fixture did exactly that and failed twenty-eight tests.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The <see cref="ChattySshPort"/> block goes last because a <c>Match</c> runs to the end of the file:
|
||||||
|
/// anything appended after it would apply only to that port.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task ReconfigureAsync()
|
private async Task ReconfigureAsync()
|
||||||
{
|
{
|
||||||
@@ -185,6 +204,10 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
||||||
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
||||||
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
||||||
|
+ $" && sed -i 's/^Port {SshPort}$/Port {SshPort}\\nPort {ChattySshPort}/' /config/sshd/sshd_config"
|
||||||
|
+ $" && printf '\\nMatch LocalPort {ChattySshPort}\\n"
|
||||||
|
+ " ForceCommand /bin/sh -c \"echo Welcome to the test server; exec /usr/lib/ssh/sftp-server\"\\n'"
|
||||||
|
+ " >> /config/sshd/sshd_config"
|
||||||
+ " && pkill -HUP sshd",
|
+ " && pkill -HUP sshd",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -456,7 +456,6 @@ public sealed class CacheStoreTests : IAsyncLifetime
|
|||||||
[InlineData(SyncEntityType.ConnectionLogEntry)]
|
[InlineData(SyncEntityType.ConnectionLogEntry)]
|
||||||
[InlineData(SyncEntityType.ActivityLogEntry)]
|
[InlineData(SyncEntityType.ActivityLogEntry)]
|
||||||
[InlineData(SyncEntityType.ObjectStore)]
|
[InlineData(SyncEntityType.ObjectStore)]
|
||||||
[InlineData(SyncEntityType.SpelunkKey)]
|
|
||||||
public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
|
public async Task AConflict_CanBeRecordedForEveryKindOfItem(SyncEntityType entityType)
|
||||||
{
|
{
|
||||||
var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");
|
var detail = System.Text.Encoding.UTF8.GetBytes($$"""{"kind":"{{entityType}}"}""");
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
namespace DodoSSH.Client.Storage.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The Spelunks this device is signed in to: one sign-in per Spelunk, sealed whole, and gone with the cache.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpelunkSignInStoreTests
|
||||||
|
{
|
||||||
|
private const string Spelunk = "https://api.spelunk.example.com";
|
||||||
|
|
||||||
|
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ASignInRoundTrips_AndIsFoundByItsSpelunk()
|
||||||
|
{
|
||||||
|
using var harness = await CacheHarness.CreateAsync();
|
||||||
|
var store = Store(harness);
|
||||||
|
|
||||||
|
(await store.FindAsync(Spelunk, Token)).ShouldBeNull("nothing is signed in yet");
|
||||||
|
|
||||||
|
var signIn = SignIn(Spelunk, "spelunkapp_one");
|
||||||
|
await store.SaveAsync(signIn, Token);
|
||||||
|
|
||||||
|
(await store.FindAsync(Spelunk, Token)).ShouldBe(signIn);
|
||||||
|
(await store.FindAsync("https://spelunk.elsewhere.example", Token)).ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SigningInAgainToTheSameSpelunk_Replaces_AndOthersStay()
|
||||||
|
{
|
||||||
|
using var harness = await CacheHarness.CreateAsync();
|
||||||
|
var store = Store(harness);
|
||||||
|
|
||||||
|
await store.SaveAsync(SignIn(Spelunk, "spelunkapp_old"), Token);
|
||||||
|
await store.SaveAsync(SignIn("https://spelunk.elsewhere.example", "spelunkapp_other"), Token);
|
||||||
|
await store.SaveAsync(SignIn(Spelunk, "spelunkapp_new"), Token);
|
||||||
|
|
||||||
|
var all = await store.ListAsync(Token);
|
||||||
|
all.Count.ShouldBe(2);
|
||||||
|
(await store.FindAsync(Spelunk, Token)).ShouldNotBeNull().Token.ShouldBe("spelunkapp_new");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgettingOne_LeavesTheOthers_AndForgettingAll_LeavesNone()
|
||||||
|
{
|
||||||
|
using var harness = await CacheHarness.CreateAsync();
|
||||||
|
var store = Store(harness);
|
||||||
|
|
||||||
|
await store.SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token);
|
||||||
|
await store.SaveAsync(SignIn("https://spelunk.elsewhere.example", "spelunkapp_two"), Token);
|
||||||
|
|
||||||
|
await store.ForgetAsync(Spelunk, Token);
|
||||||
|
(await store.ListAsync(Token)).ShouldHaveSingleItem().ApiBase.ShouldBe("https://spelunk.elsewhere.example");
|
||||||
|
|
||||||
|
await store.ForgetAllAsync(Token);
|
||||||
|
(await store.ListAsync(Token)).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task AnotherUsersCacheKey_OpensNothing()
|
||||||
|
{
|
||||||
|
using var owner = await CacheHarness.CreateAsync();
|
||||||
|
using var stranger = await CacheHarness.CreateAsync();
|
||||||
|
|
||||||
|
await Store(owner).SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token);
|
||||||
|
|
||||||
|
(await new SpelunkSignInStore(owner.Factory, stranger.Protector, TimeProvider.System).ListAsync(Token))
|
||||||
|
.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ResettingTheCache_SignsThisDeviceOutOfEverySpelunk()
|
||||||
|
{
|
||||||
|
// What signing out of DodoSSH does on disk, whatever reached Spelunk before it.
|
||||||
|
using var harness = await CacheHarness.CreateAsync();
|
||||||
|
|
||||||
|
await Store(harness).SaveAsync(SignIn(Spelunk, "spelunkapp_one"), Token);
|
||||||
|
await harness.Factory.ResetAsync(Token);
|
||||||
|
|
||||||
|
(await Store(harness).ListAsync(Token)).ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SpelunkSignInStore Store(CacheHarness harness) =>
|
||||||
|
new(harness.Factory, harness.Protector, TimeProvider.System);
|
||||||
|
|
||||||
|
private static SpelunkSignInRecord SignIn(string apiBase, string token) =>
|
||||||
|
new(apiBase, token, Guid.CreateVersion7(), "ops@example.com", "Ops", DateTimeOffset.FromUnixTimeSeconds(1_790_000_000));
|
||||||
|
}
|
||||||
@@ -43,7 +43,6 @@ public sealed class AadResourceTypeTests
|
|||||||
[InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)]
|
[InlineData(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry)]
|
||||||
[InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)]
|
[InlineData(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry)]
|
||||||
[InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)]
|
[InlineData(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore)]
|
||||||
[InlineData(SyncEntityType.SpelunkKey, CryptoSpec.AadResourceType.SpelunkKey)]
|
|
||||||
public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently(
|
public void TheTwoEnums_AreNamedAlikeAndNumberedDifferently(
|
||||||
SyncEntityType wire,
|
SyncEntityType wire,
|
||||||
CryptoSpec.AadResourceType resource)
|
CryptoSpec.AadResourceType resource)
|
||||||
@@ -78,7 +77,6 @@ public sealed class AadResourceTypeTests
|
|||||||
(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
|
(SyncEntityType.ConnectionLogEntry, CryptoSpec.AadResourceType.ConnectionLogEntry),
|
||||||
(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
|
(SyncEntityType.ActivityLogEntry, CryptoSpec.AadResourceType.ActivityLogEntry),
|
||||||
(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore),
|
(SyncEntityType.ObjectStore, CryptoSpec.AadResourceType.ObjectStore),
|
||||||
(SyncEntityType.SpelunkKey, CryptoSpec.AadResourceType.SpelunkKey),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
|
public static TheoryData<SyncEntityType, CryptoSpec.AadResourceType> Pinned
|
||||||
@@ -255,10 +253,6 @@ public sealed class AadResourceTypeTests
|
|||||||
SyncEntityType.ObjectStore => ObjectStoreCipher.Seal(
|
SyncEntityType.ObjectStore => ObjectStoreCipher.Seal(
|
||||||
NewBucket(), vaultKey, entityId, generation, version),
|
NewBucket(), vaultKey, entityId, generation, version),
|
||||||
|
|
||||||
// 14 on the wire and 17 here, and 14 here is a connection log entry.
|
|
||||||
SyncEntityType.SpelunkKey => SpelunkKeyCipher.Seal(
|
|
||||||
NewSpelunkKey(), vaultKey, entityId, generation, version),
|
|
||||||
|
|
||||||
_ => throw new ArgumentOutOfRangeException(
|
_ => throw new ArgumentOutOfRangeException(
|
||||||
nameof(wire),
|
nameof(wire),
|
||||||
wire,
|
wire,
|
||||||
@@ -419,13 +413,6 @@ public sealed class AadResourceTypeTests
|
|||||||
Region = "eu-west-1",
|
Region = "eu-west-1",
|
||||||
};
|
};
|
||||||
|
|
||||||
private static SpelunkKeySecret NewSpelunkKey() => new()
|
|
||||||
{
|
|
||||||
Label = "Spelunk at api.spelunk.example.com",
|
|
||||||
ApiBase = "https://api.spelunk.example.com",
|
|
||||||
ApiKey = "spelunk_an_example_key",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static HostGroupSecret NewGroup() => new() { Label = "production" };
|
private static HostGroupSecret NewGroup() => new() { Label = "production" };
|
||||||
|
|
||||||
private static TagSecret NewTag() => new() { Label = "pci" };
|
private static TagSecret NewTag() => new() { Label = "pci" };
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ public sealed class ItemKindsTests
|
|||||||
SyncEntityType.ConnectionLogEntry,
|
SyncEntityType.ConnectionLogEntry,
|
||||||
SyncEntityType.ActivityLogEntry,
|
SyncEntityType.ActivityLogEntry,
|
||||||
SyncEntityType.ObjectStore,
|
SyncEntityType.ObjectStore,
|
||||||
SyncEntityType.SpelunkKey,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ public sealed class SyncEngineTests
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The cursor a pull returns is past every change it read, including the changes of types it did not ask
|
/// The cursor a pull returns is past every change it read, including the changes of types it did not ask
|
||||||
/// for. A build that adds a type to the list — SpelunkKey did, ObjectStore before it — and carried on from
|
/// for. A build that adds a type to the list — ObjectStore did — and carried on from
|
||||||
/// that cursor would never receive the items of the new type made before it arrived.
|
/// that cursor would never receive the items of the new type made before it arrived.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ public sealed class CryptoSpecTests
|
|||||||
[InlineData(CryptoSpec.AadResourceType.ConnectionLogEntry, 14)]
|
[InlineData(CryptoSpec.AadResourceType.ConnectionLogEntry, 14)]
|
||||||
[InlineData(CryptoSpec.AadResourceType.ActivityLogEntry, 15)]
|
[InlineData(CryptoSpec.AadResourceType.ActivityLogEntry, 15)]
|
||||||
[InlineData(CryptoSpec.AadResourceType.ObjectStore, 16)]
|
[InlineData(CryptoSpec.AadResourceType.ObjectStore, 16)]
|
||||||
[InlineData(CryptoSpec.AadResourceType.SpelunkKey, 17)]
|
[InlineData(CryptoSpec.AadResourceType.SpelunkSignIn, 18)]
|
||||||
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
|
public void AadResourceType_HasStableWireValue(CryptoSpec.AadResourceType type, int expected)
|
||||||
{
|
{
|
||||||
((int)type).ShouldBe(expected);
|
((int)type).ShouldBe(expected);
|
||||||
|
|||||||
Reference in new issue
Block a user