Public Access
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee423b25ce | ||
|
|
38ab9b431b | ||
|
|
07bd5c9886 | ||
|
|
31aca26e89 | ||
|
|
843713b118 | ||
|
|
9c7be8ae9b | ||
|
|
3d0a66f997 | ||
|
|
689ba6c1bf | ||
|
|
6e39807cc0 | ||
|
|
d7d002c972 | ||
|
|
a6a21decbd |
No files matched your search
@@ -1326,6 +1326,19 @@ jobs:
|
||||
# inspects — it is the thing that gets deployed.
|
||||
needs: [build]
|
||||
runs-on: [linux]
|
||||
# Gitea's own container registry, on the forge this workflow runs on, under the same owner as
|
||||
# the repository - so the image is listed with the repository's packages rather than on a
|
||||
# registry of its own. Lowercase, because an image reference may not contain capitals and
|
||||
# the owner is DodoTech-Public.
|
||||
#
|
||||
# REGISTRY_USERNAME and REGISTRY_PASSWORD are a Gitea account and a token of its own with
|
||||
# package read and write, allowed to publish for that owner. Not the job's built-in
|
||||
# GITEA_TOKEN, tempting as it is here: the container registry does not accept an Actions
|
||||
# task token and answers `unauthorized` at /v2/ (go-gitea/gitea#23642). Spelunk's workflow
|
||||
# pushes the same way, with the same two secrets.
|
||||
env:
|
||||
REGISTRY: git.dodotech.cloud
|
||||
IMAGE: dodotech-public/dodossh-api
|
||||
steps:
|
||||
# Duplicated from the build job; see the comment there for why it cannot be factored
|
||||
# out. Any change here has to be made in all three.
|
||||
@@ -1451,9 +1464,6 @@ jobs:
|
||||
# changes, and where setup-buildx-action starts earning its place.
|
||||
- name: work out the tags
|
||||
id: tags
|
||||
env:
|
||||
REGISTRY: registry-docker.dodotech.cloud
|
||||
IMAGE: dodotech/dodossh-api
|
||||
run: |
|
||||
set -euo pipefail
|
||||
repo="$REGISTRY/$IMAGE"
|
||||
@@ -1578,7 +1588,7 @@ jobs:
|
||||
echo "username: ${#REGISTRY_USERNAME} characters; password: set"
|
||||
|
||||
printf '%s' "$REGISTRY_PASSWORD" \
|
||||
| docker login registry-docker.dodotech.cloud \
|
||||
| docker login "$REGISTRY" \
|
||||
--username "$REGISTRY_USERNAME" --password-stdin
|
||||
|
||||
- name: push
|
||||
@@ -1596,7 +1606,7 @@ jobs:
|
||||
# does not leave it behind.
|
||||
- name: log out
|
||||
if: always() && github.event_name != 'pull_request'
|
||||
run: docker logout registry-docker.dodotech.cloud
|
||||
run: docker logout "$REGISTRY"
|
||||
|
||||
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
|
||||
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
|
||||
|
||||
@@ -452,6 +452,57 @@ says so, and the terminal is unaffected. Any row can be switched off and on whil
|
||||
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
||||
[The Android head](#the-android-head).
|
||||
|
||||
### Reaching a server through Spelunk
|
||||
|
||||
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
|
||||
that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in
|
||||
Spelunk's API address, the server's id in Spelunk, and the credential that holds your Spelunk API key. A
|
||||
connection then asks Spelunk for a ticket, opens a WebSocket, and the agent connects to the SSH server on
|
||||
the machine's own loopback — so the server needs no open SSH port at all. Shells and file transfers both go
|
||||
this way; the relay and Spelunk are one choice, and ticking one unticks the other.
|
||||
|
||||
- **The API key is an ordinary keychain credential** — paste the key (`spelunk_…`, with the Admin role) as a
|
||||
credential's password. It is encrypted, synced and rotated like every other secret, one credential serves
|
||||
every server in that Spelunk organisation, and it is sent to the host's API address and nowhere else.
|
||||
The API address must be `https://`, except for a Spelunk on this machine.
|
||||
- **The hostname and port still matter**, though nothing dials them: the host key is pinned under them as it
|
||||
is for every host, and the port is what the agent connects to on the server.
|
||||
- **There is no falling back to a direct dial**, and a refusal is said in Spelunk's terms: a key Spelunk will
|
||||
not take, a key whose role is too low, a server outside the key's organisation, an agent that is not
|
||||
connected, an agent that could not reach the SSH port or is too old to open terminals.
|
||||
|
||||
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
|
||||
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
|
||||
Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises;
|
||||
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
|
||||
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
|
||||
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
|
||||
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
|
||||
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
|
||||
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
|
||||
so a DodoSSH older than this shows it read-only.
|
||||
|
||||
**"Open in DodoSSH" in Spelunk** starts DodoSSH with a `dodossh://spelunk/connect?api=…&server=…&name=…&host=…`
|
||||
link, or hands the link to the copy already running, and **connects straight away.** The link never
|
||||
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
|
||||
|
||||
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
|
||||
- Otherwise, when a saved host already sends an API key to that same Spelunk, DodoSSH saves a new host in
|
||||
your personal vault — named as the link names it, at the link's address (or `spelunk-<server id>`), port
|
||||
22, signing in through Spelunk with that same key credential, tagged `spelunk` (the tag is made if the
|
||||
vault has none) — and connects to it. It syncs like any host you saved yourself.
|
||||
- **The first link from a Spelunk** opens the host editor instead, filled in the same way with sign-in
|
||||
ticked and the tag on, and you choose the credential holding that Spelunk's API key once and save. Every
|
||||
later link from that Spelunk connects straight away. A link can be sent by anything on this machine, so
|
||||
an API key is never sent to an address that no saved host already sends it to.
|
||||
|
||||
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
|
||||
its own. A link that arrives while the vault is locked waits for the unlock.
|
||||
|
||||
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
|
||||
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
|
||||
The phone reaches Spelunk hosts the same way but does not answer links yet.
|
||||
|
||||
### Sharing a vault
|
||||
|
||||
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
||||
|
||||
@@ -247,6 +247,13 @@ ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
|
||||
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
||||
# name deliberately and not the package's binary name — the window does not know which package
|
||||
# started it.
|
||||
#
|
||||
# %%u — RPM's spelling of a literal %u, the field code for "one URL" — and the MimeType line make this
|
||||
# the handler for dodossh:// links, which is how Spelunk's "Open in DodoSSH" reaches the application:
|
||||
# the desktop starts the binary with the link as its one argument, and Program.Main reads and checks
|
||||
# it, or passes it to the instance already running. update-desktop-database in the post-install
|
||||
# scriptlet is what makes xdg-open see the handler. Both channels declare it, so whichever is
|
||||
# installed answers, and with both installed the desktop's own default decides.
|
||||
mkdir -p %{buildroot}%{_datadir}/applications
|
||||
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
||||
[Desktop Entry]
|
||||
@@ -255,13 +262,14 @@ Version=1.0
|
||||
Name=%{dodo_title}
|
||||
GenericName=SSH Client
|
||||
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||
Exec=%{dodo_bin}
|
||||
Exec=%{dodo_bin} %%u
|
||||
Icon=%{dodo_bin}
|
||||
Terminal=false
|
||||
StartupNotify=true
|
||||
StartupWMClass=DodoSSH
|
||||
Categories=Network;RemoteAccess;System;
|
||||
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
||||
MimeType=x-scheme-handler/dodossh;
|
||||
DESKTOP
|
||||
|
||||
# One file per size into the hicolor theme, named for the package so the two channels show different
|
||||
|
||||
@@ -119,6 +119,25 @@
|
||||
<key>LSUIElement</key>
|
||||
<false/>
|
||||
|
||||
<!--
|
||||
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
|
||||
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
|
||||
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
|
||||
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
|
||||
for a Velopack install is on first launch from /Applications.
|
||||
-->
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>dev.dodotech.dodossh.link</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>dodossh</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>© DodoTech. MIT licensed.</string>
|
||||
</dict>
|
||||
|
||||
@@ -2,9 +2,18 @@
|
||||
#
|
||||
# docker build -f src/DodoSSH.Api/Dockerfile .
|
||||
#
|
||||
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at
|
||||
# the root and all four are load-bearing here — central package management means a csproj
|
||||
# alone does not name a single version, and a build that cannot see them resolves nothing.
|
||||
# Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
|
||||
# global.json all sit at the root and all five are load-bearing here — central package
|
||||
# management means a csproj alone does not name a single version, and a build that cannot see
|
||||
# them resolves nothing.
|
||||
#
|
||||
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
|
||||
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
|
||||
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
|
||||
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
|
||||
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
|
||||
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
|
||||
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Build
|
||||
@@ -26,7 +35,7 @@ WORKDIR /src
|
||||
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
|
||||
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph
|
||||
# rather than skipping a node.
|
||||
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./
|
||||
COPY global.json NuGet.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
|
||||
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
|
||||
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
|
||||
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
|
||||
|
||||
@@ -1002,7 +1002,8 @@
|
||||
FormatString="0" ShowButtonSpinner="False" MinHeight="44"
|
||||
PlaceholderText="{Binding EditorPortPlaceholder}" />
|
||||
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" />
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||
</Grid>
|
||||
|
||||
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
|
||||
@@ -1016,6 +1017,7 @@
|
||||
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
@@ -1028,6 +1030,9 @@
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!-- Why the login is greyed out, as on the desktop. See VaultViewModel.EditorAsksForLogin. -->
|
||||
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !EditorAsksForLogin}"
|
||||
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
||||
@@ -1212,6 +1217,41 @@
|
||||
<TextBlock Classes="body"
|
||||
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
|
||||
|
||||
<!--
|
||||
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
|
||||
one unticks the other. The key is a keychain credential chosen here, never typed here. See
|
||||
VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
Text="Reach this host through Spelunk"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<StackPanel Spacing="8" IsVisible="{Binding EditorThroughSpelunk}">
|
||||
<TextBox Classes="field" Text="{Binding EditorSpelunkApi}"
|
||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
|
||||
PlaceholderText="Spelunk server id" />
|
||||
<TextBlock Classes="detail" Text="Credential holding the Spelunk API key" />
|
||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||
HorizontalAlignment="Stretch" MinHeight="44">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
|
||||
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
|
||||
<TextBlock Classes="mono" FontSize="11.5"
|
||||
Text="Sign in through Spelunk (no SSH key needed)"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="body"
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
|
||||
|
||||
<!--
|
||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
||||
|
||||
@@ -26,6 +26,20 @@ namespace DodoSSH.Client.App;
|
||||
/// </remarks>
|
||||
internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
/// <summary>
|
||||
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
|
||||
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
|
||||
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
|
||||
/// the UI thread only.
|
||||
/// </remarks>
|
||||
private static Action<SpelunkLink>? openLink;
|
||||
|
||||
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
|
||||
private static SpelunkLink? waitingLink;
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
@@ -34,12 +48,59 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
ListenForOpenedLinks();
|
||||
ShowSomethingWhileComposing(desktop);
|
||||
}
|
||||
|
||||
base.OnFrameworkInitializationCompleted();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the links the platform hands a running application, which on macOS is every link.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
|
||||
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
|
||||
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
|
||||
/// activation. The scheme is declared for it in build/macos's Info.plist template.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
|
||||
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
|
||||
/// desktops that deliver links as arguments instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void ListenForOpenedLinks()
|
||||
{
|
||||
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
activatable.Activated += (_, e) =>
|
||||
{
|
||||
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
|
||||
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
|
||||
{
|
||||
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
|
||||
private static void Receive(SpelunkLink link)
|
||||
{
|
||||
if (openLink is { } open)
|
||||
{
|
||||
open(link);
|
||||
}
|
||||
else
|
||||
{
|
||||
waitingLink = link;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a window on screen first, and composes the application behind it.
|
||||
/// </summary>
|
||||
@@ -206,9 +267,20 @@ internal sealed partial class DodoSshApp : Application
|
||||
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
|
||||
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
|
||||
var others = SingleInstance.Listen(
|
||||
paths,
|
||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
|
||||
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
|
||||
|
||||
openLink = viewModel.OpenLink;
|
||||
|
||||
if ((waitingLink ?? Program.LaunchLink) is { } first)
|
||||
{
|
||||
waitingLink = null;
|
||||
viewModel.OpenLink(first);
|
||||
}
|
||||
|
||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||
|
||||
@@ -34,6 +34,12 @@ namespace DodoSSH.Client.App.Platform;
|
||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||
/// what makes the exclusion invisible rather than mysterious.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
|
||||
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
|
||||
/// second launch like any other — so the doorbell has a second message, which carries the link to the
|
||||
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SingleInstance
|
||||
{
|
||||
@@ -46,6 +52,30 @@ internal static class SingleInstance
|
||||
/// </remarks>
|
||||
private const string ComeForward = "dodossh.come-forward";
|
||||
|
||||
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
|
||||
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
|
||||
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
|
||||
/// passes is ignored whole — the window does not even come forward for it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
|
||||
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
|
||||
/// than half-obeying it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private const string OpenLinkPrefix = "dodossh.open-link ";
|
||||
|
||||
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
|
||||
/// <remarks>
|
||||
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
|
||||
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
|
||||
/// process spends on it.
|
||||
/// </remarks>
|
||||
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
|
||||
|
||||
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||
/// <remarks>
|
||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||
@@ -86,7 +116,11 @@ internal static class SingleInstance
|
||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||
/// </param>
|
||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
||||
/// <param name="link">
|
||||
/// The link this launch was started with, if any — passed to the running instance instead of a bare
|
||||
/// request to come forward.
|
||||
/// </param>
|
||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
@@ -116,7 +150,7 @@ internal static class SingleInstance
|
||||
|
||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||
// round trip on a local pipe rather than the whole window below.
|
||||
if (AskTheRunningOneToComeForward(paths))
|
||||
if (AskTheRunningOneToComeForward(paths, link))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
@@ -142,25 +176,32 @@ internal static class SingleInstance
|
||||
/// </remarks>
|
||||
/// <param name="paths">The profile this instance claimed.</param>
|
||||
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
||||
/// <param name="openLink">
|
||||
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
|
||||
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
|
||||
/// </param>
|
||||
internal static IAsyncDisposable Listen(
|
||||
ClientPaths paths,
|
||||
Action comeForward,
|
||||
Action<SpelunkLink>? openLink = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(comeForward);
|
||||
|
||||
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
||||
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
|
||||
|
||||
listener.Start();
|
||||
|
||||
return listener;
|
||||
}
|
||||
|
||||
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
||||
/// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
|
||||
/// <returns>Whether anything was listening.</returns>
|
||||
/// <remarks>
|
||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||
/// and proves itself, and this is the part with a protocol in it.
|
||||
/// </remarks>
|
||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
@@ -175,7 +216,9 @@ internal static class SingleInstance
|
||||
|
||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||
|
||||
writer.WriteLine(ComeForward);
|
||||
// The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
|
||||
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
|
||||
writer.WriteLine(Message(link));
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -188,6 +231,42 @@ internal static class SingleInstance
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
|
||||
internal static string Message(SpelunkLink? link) =>
|
||||
link is null ? ComeForward : OpenLinkPrefix + link;
|
||||
|
||||
/// <summary>
|
||||
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
|
||||
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
|
||||
/// including a link that is almost right — the caller then does nothing at all.
|
||||
/// </returns>
|
||||
internal static bool TryRead(string? said, out SpelunkLink? link)
|
||||
{
|
||||
link = null;
|
||||
|
||||
if (said is null || said.Length > MaxMessageLength)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
|
||||
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
|
||||
{
|
||||
link = parsed;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||
/// <remarks>
|
||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||
@@ -278,14 +357,16 @@ internal static class SingleInstance
|
||||
{
|
||||
private readonly string name;
|
||||
private readonly Action comeForward;
|
||||
private readonly Action<SpelunkLink>? openLink;
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task? loop;
|
||||
|
||||
internal Doorbell(string name, Action comeForward)
|
||||
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
|
||||
{
|
||||
this.name = name;
|
||||
this.comeForward = comeForward;
|
||||
this.openLink = openLink;
|
||||
}
|
||||
|
||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||
@@ -324,11 +405,16 @@ internal static class SingleInstance
|
||||
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||
{
|
||||
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
||||
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||
if (TryRead(said, out var link))
|
||||
{
|
||||
comeForward();
|
||||
|
||||
if (link is not null)
|
||||
{
|
||||
openLink?.Invoke(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,6 +434,39 @@ internal static class SingleInstance
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
|
||||
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
|
||||
/// reader buffers underneath, and a real message is one short line.
|
||||
/// </remarks>
|
||||
private static async Task<string?> ReadBoundedLineAsync(
|
||||
StreamReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var line = new StringBuilder();
|
||||
var next = new char[1];
|
||||
|
||||
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
|
||||
{
|
||||
if (next[0] == '\n')
|
||||
{
|
||||
return line.ToString().TrimEnd('\r');
|
||||
}
|
||||
|
||||
if (line.Length == MaxMessageLength)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
line.Append(next[0]);
|
||||
}
|
||||
|
||||
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
|
||||
}
|
||||
|
||||
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||
/// <remarks>
|
||||
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Runtime.Versioning;
|
||||
using DodoSSH.Client.Session;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Registers this executable as the handler for <c>dodossh://</c> links, for this user, on Windows.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Under HKCU\Software\Classes, because the install is per user.</b> Velopack installs into the user's
|
||||
/// own profile with no elevation, and a handler registered machine-wide would need the elevation the
|
||||
/// installer deliberately never asks for. Windows merges this key into <c>HKEY_CLASSES_ROOT</c> for the
|
||||
/// user, which is all a browser consults.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Written by Velopack's install and update hooks, removed by its uninstall hook</b> — see
|
||||
/// <c>Program.Main</c>. An update rewrites it because the first update to a build that has this is how an
|
||||
/// existing installation gets it at all, and because it costs nothing to say again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Removed only when it is still ours.</b> The release and the nightly are separate installations that
|
||||
/// both answer <c>dodossh://</c>, and the last one installed holds the key. Uninstalling the other must not
|
||||
/// take the handler away from the one that is still there.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal static class WindowsUrlScheme
|
||||
{
|
||||
private const string KeyPath = @"Software\Classes\" + SpelunkLink.Scheme;
|
||||
private const string CommandPath = @"shell\open\command";
|
||||
|
||||
/// <summary>Points <c>dodossh://</c> at <paramref name="executable"/>.</summary>
|
||||
internal static void Register(string executable)
|
||||
{
|
||||
using var scheme = Registry.CurrentUser.CreateSubKey(KeyPath);
|
||||
|
||||
scheme.SetValue(string.Empty, "URL:DodoSSH");
|
||||
|
||||
// Empty, and its presence is the whole of it: this value is what makes the key a URL scheme rather
|
||||
// than a file type.
|
||||
scheme.SetValue("URL Protocol", string.Empty);
|
||||
|
||||
using (var icon = scheme.CreateSubKey("DefaultIcon"))
|
||||
{
|
||||
icon.SetValue(string.Empty, $"\"{executable}\",0");
|
||||
}
|
||||
|
||||
using var command = scheme.CreateSubKey(CommandPath);
|
||||
|
||||
command.SetValue(string.Empty, CommandFor(executable));
|
||||
}
|
||||
|
||||
/// <summary>Takes the handler away, if it is still <paramref name="executable"/>'s.</summary>
|
||||
internal static void Unregister(string executable)
|
||||
{
|
||||
string? registered;
|
||||
|
||||
using (var command = Registry.CurrentUser.OpenSubKey($@"{KeyPath}\{CommandPath}"))
|
||||
{
|
||||
registered = command?.GetValue(string.Empty) as string;
|
||||
}
|
||||
|
||||
if (IsOurs(registered, executable))
|
||||
{
|
||||
Registry.CurrentUser.DeleteSubKeyTree(KeyPath, throwOnMissingSubKey: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The command Windows runs for a link: this executable, with the link as its one argument.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves quoted. The path has spaces in it on any profile whose user name does, and the link is
|
||||
/// handed over whole as <c>%1</c> — which Windows quotes into one argument only because it is quoted
|
||||
/// here. Program.Main then reads it back and checks it before anything acts on it.
|
||||
/// </remarks>
|
||||
internal static string CommandFor(string executable) => $"\"{executable}\" \"%1\"";
|
||||
|
||||
/// <summary>Whether a registered command is the one <see cref="Register"/> writes for this executable.</summary>
|
||||
internal static bool IsOurs(string? registered, string executable) =>
|
||||
string.Equals(registered, CommandFor(executable), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -8,6 +8,16 @@ namespace DodoSSH.Client.App;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
/// <summary>
|
||||
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
|
||||
/// ordinary launch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
|
||||
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
|
||||
/// with no arguments to have.
|
||||
/// </remarks>
|
||||
internal static SpelunkLink? LaunchLink { get; private set; }
|
||||
/// <summary>
|
||||
/// Entry point.
|
||||
/// </summary>
|
||||
@@ -29,11 +39,12 @@ internal static class Program
|
||||
// and a silent installer run that reached either would be a background process holding the cache
|
||||
// file open during the very file operations the installer is performing.
|
||||
//
|
||||
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
|
||||
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
|
||||
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
|
||||
// before unlock and touching no encrypted content.
|
||||
VelopackApp.Build().Run();
|
||||
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
|
||||
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
|
||||
// after an update — a schema migration — already runs on every ordinary launch from
|
||||
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
|
||||
// have touch only the registry; see RunVelopack.
|
||||
RunVelopack();
|
||||
|
||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||
|
||||
@@ -51,16 +62,100 @@ internal static class Program
|
||||
*/
|
||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||
|
||||
using var claim = SingleInstance.Claim(paths);
|
||||
// Read before the claim, because a launch that carries a link and finds DodoSSH already running
|
||||
// passes the link on rather than merely asking for the window.
|
||||
var link = LinkFrom(args);
|
||||
|
||||
using var claim = SingleInstance.Claim(paths, link: link);
|
||||
|
||||
if (claim is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LaunchLink = link;
|
||||
|
||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||
}
|
||||
|
||||
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
|
||||
/// <remarks>
|
||||
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
|
||||
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
|
||||
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
|
||||
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
|
||||
/// <see cref="WindowsUrlScheme"/>.
|
||||
/// </remarks>
|
||||
private static void RunVelopack()
|
||||
{
|
||||
var velopack = VelopackApp.Build();
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
velopack = velopack
|
||||
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
|
||||
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
|
||||
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
|
||||
}
|
||||
|
||||
velopack.Run();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
|
||||
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
|
||||
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
|
||||
/// </remarks>
|
||||
private static SpelunkLink? LinkFrom(string[] args) =>
|
||||
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
|
||||
&& SpelunkLink.TryParse(candidate, out var link)
|
||||
? link
|
||||
: null;
|
||||
|
||||
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
|
||||
/// <remarks>
|
||||
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
|
||||
/// convenience could not be set up would be the wrong trade — the application works without it, and the
|
||||
/// only thing lost is Spelunk's button opening it.
|
||||
/// </remarks>
|
||||
private static void RegisterUrlScheme()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WindowsUrlScheme.Register(executable);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||
{
|
||||
// See the remarks.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
|
||||
private static void UnregisterUrlScheme()
|
||||
{
|
||||
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WindowsUrlScheme.Unregister(executable);
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||
{
|
||||
// See the remarks on RegisterUrlScheme.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
||||
/// </summary>
|
||||
|
||||
@@ -578,7 +578,8 @@
|
||||
-->
|
||||
<Grid ColumnDefinitions="*,8,78">
|
||||
<TextBox Grid.Column="0" Text="{Binding EditorUsername}"
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" />
|
||||
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40"
|
||||
IsEnabled="{Binding EditorAsksForLogin}" />
|
||||
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
|
||||
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
|
||||
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
|
||||
@@ -599,6 +600,7 @@
|
||||
-->
|
||||
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
|
||||
SelectedItem="{Binding EditorSelectedAuthentication}"
|
||||
IsEnabled="{Binding EditorAsksForLogin}"
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
@@ -612,6 +614,15 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Why the two controls above are greyed out for a host Spelunk signs in to: disabled rather
|
||||
than hidden, so a login somebody chose is still there to see and comes back if the box in
|
||||
the Spelunk card is unticked before saving. See VaultViewModel.EditorAsksForLogin.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding !EditorAsksForLogin}"
|
||||
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
|
||||
|
||||
<!--
|
||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
||||
@@ -692,6 +703,60 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
â—† THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
|
||||
because a host has one route. A host ticked here is reached through the Spelunk agent already
|
||||
on the server, so it needs no open SSH port at all. The fields appear only when the box is
|
||||
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
|
||||
and synced like every other secret. See VaultViewModel.Spelunk.cs.
|
||||
-->
|
||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||
<StackPanel Spacing="6">
|
||||
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Connect through Spelunk" FontSize="13"
|
||||
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<StackPanel Spacing="6" IsVisible="{Binding EditorThroughSpelunk}">
|
||||
<TextBox Text="{Binding EditorSpelunkApi}" Height="36"
|
||||
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
|
||||
PlaceholderText="Spelunk server id" />
|
||||
<!--
|
||||
Credentials only: the key is the credential's password. A deleted one keeps a
|
||||
placeholder, as the authentication picker's does, so an unrelated edit cannot drop it.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="10.5" Text="Credential holding the Spelunk API key" />
|
||||
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
|
||||
SelectedItem="{Binding EditorSelectedSpelunkKey}"
|
||||
HorizontalAlignment="Stretch" Height="40">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:AuthenticationChoice">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<!--
|
||||
Spelunk signs the user in: a key pair made in memory for each connection, its public
|
||||
half sent with the ticket, and the agent's one-connection SSH server accepting only
|
||||
that. Ticking it greys out the login above. See SpelunkRoute.SignInThroughSpelunk.
|
||||
-->
|
||||
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" VerticalContentAlignment="Center">
|
||||
<TextBlock Text="Sign in through Spelunk (no SSH key needed)" FontSize="12.5"
|
||||
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
<!--
|
||||
What the route asks for and what it costs, said where the decision is made: an Admin-role
|
||||
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
|
||||
only while Spelunk and its agent are.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11.5"
|
||||
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save a Spelunk API key with the Admin role as a credential's password and choose it here; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||
key is refused outright with no way to continue past it — so a legitimately rebuilt server
|
||||
|
||||
@@ -297,6 +297,34 @@ public sealed record HostSecret : IVaultSecret
|
||||
/// </remarks>
|
||||
public bool RelayEnabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// How this host is reached through Spelunk, or null for a host that is dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A host carrying one is never dialled directly and never falls back to it, for the reason the relay
|
||||
/// does not: the user chose this route because the direct one does not exist or does not work, and a
|
||||
/// connection that quietly went another way would be the box saying one thing and the connection doing
|
||||
/// another. See <see cref="SpelunkRoute"/> for what the route is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b><see cref="Hostname"/> and <see cref="Port"/> still matter, and not for dialling.</b> The host key
|
||||
/// is pinned under them, as it is for every host, so they have to name this machine stably — a host made
|
||||
/// from a Spelunk link takes the server's address, or a name made from its id when Spelunk gave none.
|
||||
/// The port is also what the agent is asked to connect to on the server's own loopback.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Mutually exclusive with <see cref="RelayEnabled"/>.</b> Two routes to one host is the same failure
|
||||
/// as two bindings: the editor, the connect path and the user would each be free to guess which one is
|
||||
/// meant. <see cref="TryValidate"/> refuses the pair.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public SpelunkRoute? Spelunk { get; init; }
|
||||
|
||||
/// <summary>Whether Spelunk signs the user in to this host, so it needs no login of its own.</summary>
|
||||
/// <remarks>See <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</remarks>
|
||||
public bool SignsInThroughSpelunk => Spelunk?.SignInThroughSpelunk is true;
|
||||
|
||||
/// <summary>
|
||||
/// Checks the fields that must hold before this can be stored.
|
||||
/// </summary>
|
||||
@@ -332,7 +360,8 @@ public sealed record HostSecret : IVaultSecret
|
||||
|
||||
return PinnedPathsAreStorable(out reason)
|
||||
&& PortForwardsAreStorable(out reason)
|
||||
&& ReferencesAreStorable(out reason);
|
||||
&& ReferencesAreStorable(out reason)
|
||||
&& RouteIsStorable(out reason);
|
||||
}
|
||||
|
||||
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
|
||||
@@ -356,6 +385,24 @@ public sealed record HostSecret : IVaultSecret
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Checks the Spelunk route, if there is one, and that it is the only route.</summary>
|
||||
private bool RouteIsStorable([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (Spelunk is null)
|
||||
{
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (RelayEnabled)
|
||||
{
|
||||
reason = "A host is reached through the server relay or through Spelunk, not both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return Spelunk.TryValidate(out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the paths pinned on this host.
|
||||
/// </summary>
|
||||
|
||||
@@ -119,12 +119,44 @@ public static class HostSecretCodec
|
||||
/// </remarks>
|
||||
public const int PortForwardsSchemaVersion = 8;
|
||||
|
||||
/// <summary>The version that introduced <see cref="HostSecret.Spelunk"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its own version, by the rule this file follows, and it matters more here than for a tag. An older
|
||||
/// build that decoded a Spelunk host as editable would drop the route on the next save — and the host
|
||||
/// would then be dialled directly, at an address that exists only so its key has a name to be pinned
|
||||
/// under. Written at this version, the older build shows the host, refuses to edit it, and says to update.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Additive, like every version but <see cref="PortInheritSchemaVersion"/>: the route is one more
|
||||
/// property, and a build that does not know it skips it and still reads a valid host. It follows
|
||||
/// <see cref="PortForwardsSchemaVersion"/>, whose forwards reached main first.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const int SpelunkSchemaVersion = 9;
|
||||
|
||||
/// <summary>The version that introduced <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Its own version, and nested in the existing <c>spelunk</c> object rather than beside it, because it
|
||||
/// is part of that one decision. A build that knows 9 skips the unknown member and reads a valid host —
|
||||
/// a Spelunk host with no login of its own — and it must not save that back: the flag would be dropped
|
||||
/// and the host would start asking for a password it never had. Written at this version, that build
|
||||
/// shows the host, refuses to edit it, and says to update.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only a route that signs in is written here. Every other Spelunk host stays at
|
||||
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public const int SpelunkSignInSchemaVersion = 10;
|
||||
|
||||
/// <summary>The highest schema version this build can write.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public const int CurrentSchemaVersion = PortForwardsSchemaVersion;
|
||||
public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
|
||||
|
||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||
@@ -175,6 +207,9 @@ public static class HostSecretCodec
|
||||
PortForwards = host.PortForwards.Count == 0
|
||||
? null
|
||||
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
||||
// Last of all, and absent for a host that is dialled, so every host without a route encodes
|
||||
// exactly as it did before routes existed.
|
||||
Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null,
|
||||
};
|
||||
|
||||
return JsonSerializer.SerializeToUtf8Bytes(
|
||||
@@ -255,6 +290,16 @@ public static class HostSecretCodec
|
||||
version = Math.Max(version, PortForwardsSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.Spelunk is not null)
|
||||
{
|
||||
version = Math.Max(version, SpelunkSchemaVersion);
|
||||
}
|
||||
|
||||
if (host.SignsInThroughSpelunk)
|
||||
{
|
||||
version = Math.Max(version, SpelunkSignInSchemaVersion);
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
@@ -340,6 +385,7 @@ public static class HostSecretCodec
|
||||
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
|
||||
|
||||
PortForwards = forwards,
|
||||
Spelunk = RouteFrom(parsed.Spelunk),
|
||||
};
|
||||
|
||||
if (!candidate.TryValidate(out _))
|
||||
@@ -389,6 +435,26 @@ public static class HostSecretCodec
|
||||
forwards = PortForwardList.Create(rules);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The route as stored, or null when there is none.</summary>
|
||||
/// <remarks>
|
||||
/// Missing parts come through as empty values rather than making the route vanish, so that
|
||||
/// <see cref="HostSecret.TryValidate"/> refuses the host. Reading a half-written route as "no route"
|
||||
/// would turn a Spelunk host into one that is dialled directly, which is the one reading of a damaged
|
||||
/// payload that does something the user never asked for.
|
||||
/// </remarks>
|
||||
private static SpelunkRoute? RouteFrom(SpelunkPayloadDocument? parsed) =>
|
||||
parsed is null
|
||||
? null
|
||||
: new SpelunkRoute
|
||||
{
|
||||
ApiBase = parsed.Api ?? string.Empty,
|
||||
ServerId = parsed.ServerId ?? Guid.Empty,
|
||||
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
|
||||
|
||||
// False folds to the default, as AsksForPassword's does: one state, one spelling.
|
||||
SignInThroughSpelunk = parsed.SignIn is true,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -462,6 +528,40 @@ internal sealed class HostPayloadDocument
|
||||
|
||||
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
|
||||
public PortForwardDocument?[]? PortForwards { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Last, and null for a host that is dialled, for the reason <see cref="TagIds"/> gives. One nested object
|
||||
/// rather than three loose properties, because the three are one decision — a server id without its
|
||||
/// Spelunk means nothing — and an object makes "all or none" the shape of the JSON.
|
||||
/// </remarks>
|
||||
public SpelunkPayloadDocument? Spelunk { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The serialised shape of a <see cref="SpelunkRoute"/>. Nullable because it models untrusted input.</summary>
|
||||
internal sealed class SpelunkPayloadDocument
|
||||
{
|
||||
public string? Api { get; set; }
|
||||
|
||||
public Guid? ServerId { get; set; }
|
||||
|
||||
public Guid? ApiKeyCredentialId { get; set; }
|
||||
|
||||
/// <remarks>
|
||||
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
|
||||
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
|
||||
/// </remarks>
|
||||
public bool? SignIn { get; set; }
|
||||
|
||||
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
|
||||
{
|
||||
Api = route.ApiBase,
|
||||
ServerId = route.ServerId,
|
||||
ApiKeyCredentialId = route.ApiKeyCredentialId,
|
||||
|
||||
// Null rather than false, so a route that does not sign in encodes exactly as it did before the
|
||||
// field existed — the rule AsksForPassword follows.
|
||||
SignIn = route.SignInThroughSpelunk ? true : null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
|
||||
|
||||
@@ -168,6 +168,18 @@ public static class HostSecretMerge
|
||||
remote.AsksForPassword,
|
||||
conflicts,
|
||||
static asked => asked is true ? "a typed password" : "the group's binding"),
|
||||
|
||||
// Here rather than beside RelayEnabled, because it is a reference too — to a Spelunk server and to
|
||||
// the credential holding its key — and it merges as one value for the reason the jump chain does:
|
||||
// an API address from one side and a server id from the other would be a route neither user
|
||||
// chose, to a server that may not exist on that Spelunk.
|
||||
Spelunk = Field(
|
||||
nameof(HostSecret.Spelunk),
|
||||
ancestor.Spelunk,
|
||||
local.Spelunk,
|
||||
remote.Spelunk,
|
||||
conflicts,
|
||||
static route => route is null ? "dialled directly" : $"server {route.ServerId} on {route.ApiBase}"),
|
||||
};
|
||||
|
||||
private static string Text(
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace DodoSSH.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// How a host is reached through Spelunk: which Spelunk, which of its servers, and which key opens the way.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Spelunk manages servers through an agent that runs on each of them and keeps a connection open to
|
||||
/// Spelunk. A host carrying one of these is never dialled: a connection to it asks that Spelunk for a
|
||||
/// ticket, opens a WebSocket, and Spelunk hands the bytes to the agent, which connects to the SSH server on
|
||||
/// the machine's own loopback. That reaches a server with no public SSH port at all — which is the case
|
||||
/// this exists for — and it is why nothing here names an address to dial.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Inside the host's encrypted payload, and nowhere else.</b> Unlike the relay, nothing about this has to
|
||||
/// be readable by the DodoSSH server: the tunnel is opened by this client, against Spelunk, with a key only
|
||||
/// the vault holds. So the concession ADR 0004 argues for the relay's address is not made for this.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The API key is referenced, not embedded.</b> <see cref="ApiKeyCredentialId"/> names an ordinary
|
||||
/// keychain credential whose password is the key, for the reason <see cref="HostSecret.CredentialId"/>
|
||||
/// gives: one Spelunk key is the key for every server in its organisation, and a copy per host is twenty
|
||||
/// places to rotate it and one to forget. It also means the key is encrypted, synced, merged and shown in
|
||||
/// the keychain exactly as a password is, with no item type of its own to build and keep in step.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkRoute
|
||||
{
|
||||
/// <summary>The longest API address stored.</summary>
|
||||
/// <remarks>
|
||||
/// Far beyond any real address, and there for the reason <see cref="HostSecret.MaxPinnedPathLength"/>
|
||||
/// is: this is rendered by every device that decrypts the host, and an unbounded string is the shape a
|
||||
/// corrupted payload takes.
|
||||
/// </remarks>
|
||||
public const int MaxApiBaseLength = 512;
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk API's base address, as <see cref="TryNormalizeApiBase"/> writes it — for example
|
||||
/// <c>https://api.spelunk.example.com</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A string in its normalised form rather than a <see cref="Uri"/>, because this is compared: two hosts
|
||||
/// on the same Spelunk must say so in the same bytes, or a link from Spelunk would not find the host
|
||||
/// already saved for it and the merge would see a change nobody made.
|
||||
/// </remarks>
|
||||
public required string ApiBase { get; init; }
|
||||
|
||||
/// <summary>The server's id in Spelunk.</summary>
|
||||
public required Guid ServerId { get; init; }
|
||||
|
||||
/// <summary>The keychain credential whose password is the Spelunk API key.</summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public required Guid ApiKeyCredentialId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether Spelunk signs the user in, so the host needs no SSH key, credential or password of its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For each connection this client makes a throwaway key pair in memory and sends the public half with
|
||||
/// the ticket request, which the API key authorises. The agent then runs a one-connection SSH server on
|
||||
/// the machine whose only authorised key is that one, and says which account it logs in as. It is real
|
||||
/// SSH to the server's own host key, so shells, file transfer and port forwards work as for any host,
|
||||
/// and the key is gone when the connection is.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>What the host says about logging in is then not asked.</b> Its username, key, credential and
|
||||
/// typed-password choice — its own or a group's — are left alone rather than refused, because they are
|
||||
/// the host's answer for the day this is unticked, and refusing a host that carries them would make a
|
||||
/// merge of two honest edits undecodable. The editor stores no binding beside this, and the connect path
|
||||
/// never reads one for a host that has it. See <c>VaultViewModel.TryBuildAuthentication</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written from <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>, and only when true, so a route
|
||||
/// without it encodes exactly as it did before it existed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool SignInThroughSpelunk { get; init; }
|
||||
|
||||
/// <summary>Whether this is the route to the given server on the given Spelunk.</summary>
|
||||
/// <param name="apiBase">A normalised API base, as <see cref="TryNormalizeApiBase"/> writes it.</param>
|
||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||
public bool IsFor(string apiBase, Guid serverId) =>
|
||||
ServerId == serverId && string.Equals(ApiBase, apiBase, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>Checks that this route can be stored and opened.</summary>
|
||||
public bool TryValidate([NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (!TryNormalizeApiBase(ApiBase, out var normalized, out reason))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stored normalised or not at all, for the comparison the remarks on ApiBase depend on.
|
||||
if (!string.Equals(normalized, ApiBase, StringComparison.Ordinal))
|
||||
{
|
||||
reason = "The Spelunk API address is not in its normal form.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ServerId == Guid.Empty)
|
||||
{
|
||||
reason = "A host reached through Spelunk needs the id of its Spelunk server.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (ApiKeyCredentialId == Guid.Empty)
|
||||
{
|
||||
reason = "A host reached through Spelunk needs a credential holding its Spelunk API key.";
|
||||
return false;
|
||||
}
|
||||
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a Spelunk API address as a user or a link gives it, into the one form it is stored in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>HTTPS, or plain HTTP to this machine only.</b> The API key travels in a header on every tunnel, so
|
||||
/// an address that sends it in clear across a network is refused rather than stored. A Spelunk running
|
||||
/// on the developer's own box is the one place plain HTTP is harmless, and it is allowed there so the
|
||||
/// feature can be tried against one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// No user name, password, query or fragment: none of them means anything to the API, and the first two
|
||||
/// are the classic way to make an address read as one host while naming another. What is left is the
|
||||
/// scheme, the authority and any path prefix, lower-cased where URLs are case-insensitive and without a
|
||||
/// trailing slash — so the same Spelunk typed twice is stored the same way twice.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool TryNormalizeApiBase(
|
||||
string? text,
|
||||
[NotNullWhen(true)] out string? apiBase,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
apiBase = null;
|
||||
|
||||
var trimmed = text?.Trim() ?? string.Empty;
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
reason = "A host reached through Spelunk needs the address of the Spelunk API.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (trimmed.Length > MaxApiBaseLength
|
||||
|| !Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| uri.Scheme is not ("https" or "http")
|
||||
|| uri.HostNameType is UriHostNameType.Unknown
|
||||
|| string.IsNullOrEmpty(uri.Host))
|
||||
{
|
||||
reason = "The Spelunk API address must be a web address, like https://api.spelunk.example.com.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.Ordinal) && !uri.IsLoopback)
|
||||
{
|
||||
reason = "The Spelunk API address must use https, because the API key is sent with every connection.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (uri.UserInfo.Length > 0 || uri.Query.Length > 0 || uri.Fragment.Length > 0)
|
||||
{
|
||||
reason = "The Spelunk API address cannot carry a user name, a query or a fragment.";
|
||||
return false;
|
||||
}
|
||||
|
||||
apiBase = uri.GetLeftPart(UriPartial.Path).TrimEnd('/');
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A way to a host that is not a dial: one connection long, handed to SSH.NET as a loopback proxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two things answer to this — the server relay (<see cref="RelayRoute"/>) and a Spelunk tunnel
|
||||
/// (<see cref="SpelunkTunnelRoute"/>) — and both are the same shape underneath: a WebSocket carrying the
|
||||
/// SSH bytes, and a <see cref="RelayBridge"/> that turns it into an address SSH.NET can be given. What the
|
||||
/// connect path needs from either is exactly that address, the reason the far end gave if it hung up, and a
|
||||
/// way to let go of it when the connect never got as far as handing it over.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Handing <see cref="Proxy"/> to a connection hands the bridge over with it, so the connection's own
|
||||
/// disposal is what closes the route. <see cref="IAsyncDisposable.DisposeAsync"/> is for a caller that ends
|
||||
/// up not connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IConnectionRoute : IAsyncDisposable
|
||||
{
|
||||
/// <summary>The loopback proxy to connect through, which owns the route from then on.</summary>
|
||||
SshLoopbackProxy Proxy { get; }
|
||||
|
||||
/// <summary>
|
||||
/// What the far end said when it closed, in words a user can act on; null when it has not closed, or
|
||||
/// closed with nothing to say.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// For a connect that failed. SSH.NET reports a route that hung up as a stream that ended, which reads
|
||||
/// exactly like the direct connection the route was chosen to get around — so the route's own reason,
|
||||
/// when it gave one, replaces it.
|
||||
/// </remarks>
|
||||
string? Failure { get; }
|
||||
}
|
||||
@@ -40,7 +40,7 @@ public interface IServerRelay
|
||||
/// disposal is what closes the relay. <see cref="DisposeAsync"/> is for a caller that ends up not
|
||||
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||
/// </remarks>
|
||||
public sealed class RelayRoute : IAsyncDisposable
|
||||
public sealed class RelayRoute : IConnectionRoute
|
||||
{
|
||||
private readonly RelayBridge bridge;
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// A <c>dodossh://spelunk/connect</c> link from Spelunk's web interface, read and checked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Spelunk puts one of these behind a "Open in DodoSSH" button on a server's page:
|
||||
/// <c>dodossh://spelunk/connect?api=…&server=…&name=…&host=…</c>. It says which Spelunk and which
|
||||
/// server, and what to call it. <b>It never carries a credential</b>, and nothing reads one out of it: the
|
||||
/// API key is the user's, chosen in the vault, and a link that could supply one would be a link that could
|
||||
/// supply somebody else's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Anything on the machine can hand this application a link</b> — a web page, a mail, another program —
|
||||
/// so a link is data to check, never an instruction. It is refused whole unless it is exactly this shape,
|
||||
/// and the most it can ever do is connect to a host a link made before, add one for another server on a
|
||||
/// Spelunk a saved host already sends its API key to — with that same key — or open the editor on a new
|
||||
/// one that the user then has to finish and save. It never sends an API key anywhere a saved host does
|
||||
/// not already send it. See <c>VaultViewModel.OpenSpelunkLinkAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="ApiBase">The Spelunk API's base address, normalised as <see cref="SpelunkRoute"/> stores it.</param>
|
||||
/// <param name="ServerId">The server's id in Spelunk.</param>
|
||||
/// <param name="Name">What Spelunk calls the server, cleaned for display.</param>
|
||||
/// <param name="Host">The server's public address as Spelunk knows it, or null when it gave none.</param>
|
||||
public sealed record SpelunkLink(string ApiBase, Guid ServerId, string Name, string? Host)
|
||||
{
|
||||
/// <summary>The URL scheme DodoSSH registers.</summary>
|
||||
public const string Scheme = "dodossh";
|
||||
|
||||
/// <summary>The longest link read at all. Anything longer is refused before it is parsed.</summary>
|
||||
/// <remarks>
|
||||
/// A real link is a few hundred characters. The bound is what a second launch may put down the doorbell
|
||||
/// pipe, and what the parser is asked to walk — neither of which should be up to the sender.
|
||||
/// </remarks>
|
||||
public const int MaxLength = 4096;
|
||||
|
||||
/// <summary>The longest name kept. Longer is cut, not refused: it is only a label.</summary>
|
||||
public const int MaxNameLength = 100;
|
||||
|
||||
private const string Authority = "spelunk";
|
||||
private const string Path = "/connect";
|
||||
|
||||
/// <summary>
|
||||
/// The hostname a new host from this link is given: Spelunk's address for the server, or a name made
|
||||
/// from its id when there is none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// It is not dialled — the tunnel ignores it — but the host key is pinned under it, so it has to be
|
||||
/// stable for this server and unlike any other. The id is both; the address is also what the user would
|
||||
/// recognise, and a key already trusted for a direct connection to that address is found again.
|
||||
/// </remarks>
|
||||
public string Hostname => Host ?? $"spelunk-{ServerId}";
|
||||
|
||||
/// <summary>Whether a string looks like it is meant to be one of these, before checking it is one.</summary>
|
||||
/// <remarks>
|
||||
/// For the launcher, which is handed arguments of every kind and needs to tell a link that failed the
|
||||
/// checks from something that was never a link.
|
||||
/// </remarks>
|
||||
public static bool LooksLikeOne(string? text) =>
|
||||
text is not null && text.StartsWith(Scheme + ":", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Reads a link, or refuses it.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Strict on everything that decides where anything goes: the scheme, <c>spelunk</c> as the authority
|
||||
/// with no user, password or port, <c>/connect</c> as the path, no fragment, each known parameter at
|
||||
/// most once, an API address <see cref="SpelunkRoute.TryNormalizeApiBase"/> accepts, and a server id
|
||||
/// that is a real one. Unknown parameters are ignored rather than refused, so a newer Spelunk can add
|
||||
/// one without every existing DodoSSH ignoring its links outright.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Lenient only on the name, which is display text: control characters become spaces and a long one is
|
||||
/// cut. A missing name falls back to the host, then to a plain one.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static bool TryParse(string? text, [NotNullWhen(true)] out SpelunkLink? link)
|
||||
{
|
||||
link = null;
|
||||
|
||||
if (text is null
|
||||
|| text.Length > MaxLength
|
||||
|| text.Any(character => char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
|| !Uri.TryCreate(text, UriKind.Absolute, out var uri)
|
||||
|| !string.Equals(uri.Scheme, Scheme, StringComparison.Ordinal)
|
||||
|| !string.Equals(uri.Host, Authority, StringComparison.Ordinal)
|
||||
|| uri.UserInfo.Length > 0
|
||||
|| !uri.IsDefaultPort
|
||||
|| !string.Equals(uri.AbsolutePath, Path, StringComparison.Ordinal)
|
||||
|| uri.Fragment.Length > 0
|
||||
|| !TryReadQuery(QueryOf(text), out var parameters)
|
||||
|| !parameters.TryGetValue("api", out var api)
|
||||
|| !SpelunkRoute.TryNormalizeApiBase(api, out var apiBase, out _)
|
||||
|| !parameters.TryGetValue("server", out var server)
|
||||
|| !Guid.TryParseExact(server, "D", out var serverId)
|
||||
|| serverId == Guid.Empty)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string? host = null;
|
||||
|
||||
if (parameters.TryGetValue("host", out var given) && given.Trim() is { Length: > 0 } trimmed)
|
||||
{
|
||||
if (trimmed.Length > 253 || Uri.CheckHostName(trimmed) is UriHostNameType.Unknown)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
host = trimmed;
|
||||
}
|
||||
|
||||
var name = Clean(parameters.GetValueOrDefault("name")) ?? host ?? "Spelunk server";
|
||||
|
||||
link = new SpelunkLink(apiBase, serverId, name, host);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The link written out again, in the one form <see cref="TryParse"/> reads back to the same value.</summary>
|
||||
/// <remarks>
|
||||
/// What a second launch sends the running instance, so the instance parses a link this build wrote rather
|
||||
/// than whatever the browser handed over.
|
||||
/// </remarks>
|
||||
public override string ToString()
|
||||
{
|
||||
var builder = new StringBuilder($"{Scheme}://{Authority}{Path}?api=")
|
||||
.Append(Uri.EscapeDataString(ApiBase))
|
||||
.Append("&server=")
|
||||
.Append(ServerId.ToString("D"))
|
||||
.Append("&name=")
|
||||
.Append(Uri.EscapeDataString(Name));
|
||||
|
||||
if (Host is not null)
|
||||
{
|
||||
builder.Append("&host=").Append(Uri.EscapeDataString(Host));
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits a query into its known parameters, refusing one that names any of them twice.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// By hand rather than through a web utility, because this project has none and because the rule that
|
||||
/// matters — twice is refused, not "last wins" — is one no general-purpose parser applies. <c>+</c> is a
|
||||
/// space, as a browser's form encoding writes it.
|
||||
/// </remarks>
|
||||
private static bool TryReadQuery(string query, out Dictionary<string, string> parameters)
|
||||
{
|
||||
parameters = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var pair in query.Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var equals = pair.IndexOf('=', StringComparison.Ordinal);
|
||||
var key = equals < 0 ? pair : pair[..equals];
|
||||
var value = equals < 0 ? string.Empty : pair[(equals + 1)..];
|
||||
|
||||
if (key is not ("api" or "server" or "name" or "host"))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string decoded;
|
||||
|
||||
try
|
||||
{
|
||||
decoded = Uri.UnescapeDataString(value.Replace('+', ' '));
|
||||
}
|
||||
catch (UriFormatException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!parameters.TryAdd(key, decoded))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>The query exactly as it was written, rather than as <see cref="Uri"/> re-escapes it.</summary>
|
||||
/// <remarks>
|
||||
/// <see cref="Uri"/> has no rules for a scheme it does not know, and how much of the query it unescapes
|
||||
/// for one is its business. Taking the text after the first <c>?</c> means every parameter is decoded
|
||||
/// exactly once, here.
|
||||
/// </remarks>
|
||||
private static string QueryOf(string text) =>
|
||||
text.IndexOf('?', StringComparison.Ordinal) is var at and >= 0 ? text[(at + 1)..] : string.Empty;
|
||||
|
||||
private static string? Clean(string? name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var cleaned = new string([.. name.Trim().Select(character => char.IsControl(character) ? ' ' : character)]);
|
||||
|
||||
return cleaned.Length > MaxNameLength ? cleaned[..MaxNameLength].TrimEnd() : cleaned;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// The throwaway key one connection signs in to a Spelunk server with.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For a host set to sign in through Spelunk — see <c>SpelunkRoute.SignInThroughSpelunk</c> — each connection
|
||||
/// makes one of these, sends <see cref="PublicKeyLine"/> with its ticket request, and authenticates with
|
||||
/// <see cref="Credential"/> once the tunnel is up. Spelunk's agent runs a one-connection SSH server whose only
|
||||
/// authorised key is this one, so the key is worth nothing after the connection it was made for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Made in memory and never kept.</b> Nothing writes it to a disk or to the vault, and nothing logs it:
|
||||
/// <see cref="ToString"/> says what it is and nothing of what it holds. Ed25519, which is what
|
||||
/// <see cref="SshKeyGenerator"/> makes instantly and what every OpenSSH the agent could start accepts.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SpelunkSignIn
|
||||
{
|
||||
/// <summary>The account Spelunk signs in as when its answer does not name one.</summary>
|
||||
public const string DefaultUser = "root";
|
||||
|
||||
/// <summary>The comment the public key carries, which is what Spelunk and the server's logs see.</summary>
|
||||
internal const string KeyComment = "dodossh-spelunk-sign-in";
|
||||
|
||||
private SpelunkSignIn(string publicKeyLine, SshPrivateKeyCredential credential)
|
||||
{
|
||||
PublicKeyLine = publicKeyLine;
|
||||
Credential = credential;
|
||||
}
|
||||
|
||||
/// <summary>The public half, as one OpenSSH <c>authorized_keys</c> line: <c>ssh-ed25519 AAAA… comment</c>.</summary>
|
||||
public string PublicKeyLine { get; }
|
||||
|
||||
/// <summary>The private half, in the form a connection authenticates with.</summary>
|
||||
public SshPrivateKeyCredential Credential { get; }
|
||||
|
||||
/// <summary>Makes a fresh key pair for one connection.</summary>
|
||||
public static SpelunkSignIn Create()
|
||||
{
|
||||
var key = SshKeyGenerator.Generate(SshKeyAlgorithm.Ed25519, KeyComment);
|
||||
|
||||
return new SpelunkSignIn(
|
||||
key.PublicKeyLine.Trim(),
|
||||
new SshPrivateKeyCredential(Encoding.UTF8.GetBytes(key.PrivateKeyArmour), Passphrase: null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>Says nothing of the key, so one that reaches a log by accident takes nothing with it.</remarks>
|
||||
public override string ToString() => "Spelunk sign-in key";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The credential of a connection that will sign in through Spelunk, before the tunnel has made its key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A host that signs in through Spelunk is resolved to a login before anything is opened — the same
|
||||
/// resolution every host goes through — but its key does not exist until the tunnel is opened, and the
|
||||
/// account is whatever Spelunk then says. This stands in for the key until the route replaces it, together
|
||||
/// with the username, as the connection is made through it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It authenticates nothing.</b> The SSH layer knows only passwords and private keys, and refuses any other
|
||||
/// credential before it dials — so a path that forgot to take the route's key fails closed rather than
|
||||
/// sending something else in its place.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed record SpelunkSignInPending : SshCredential
|
||||
{
|
||||
/// <summary>The one value; there is nothing in it to tell two apart.</summary>
|
||||
public static SpelunkSignInPending Instance { get; } = new();
|
||||
|
||||
private SpelunkSignInPending()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,661 @@
|
||||
using System.Globalization;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Session;
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk's tunnel: the routes, the subprotocol and the close codes, as Spelunk defines them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same two steps as the server relay's, and on purpose, because the bridge on this side is the relay's
|
||||
/// and it only has to be told where to go. An authenticated POST decides everything — whose key, which
|
||||
/// server, whether the key may — and answers with a ticket; the WebSocket that spends the ticket carries no
|
||||
/// authority of its own, and after the upgrade every binary message is simply the next bytes of the SSH
|
||||
/// stream, both ways, with nothing else in the socket at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// This is Spelunk's contract, not DodoSSH's, which is why it lives here and not in
|
||||
/// <c>DodoSSH.Contracts</c>: nothing on the DodoSSH server takes part in a Spelunk tunnel.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class SpelunkTunnelProtocol
|
||||
{
|
||||
/// <summary>The only subprotocol this version speaks.</summary>
|
||||
public const string SubProtocol = "spelunk.tunnel.v1";
|
||||
|
||||
/// <summary>The prefix of the second subprotocol an upgrade offers, which carries the ticket.</summary>
|
||||
/// <remarks>
|
||||
/// A header rather than the query string, for the reason the relay gives: a URL is what proxies, access
|
||||
/// logs and traces keep. Spelunk echoes <see cref="SubProtocol"/> alone.
|
||||
/// </remarks>
|
||||
public const string TicketProtocolPrefix = "ticket.";
|
||||
|
||||
/// <summary>
|
||||
/// Close code for an agent that could not connect to the port on its own machine — or that is too old to
|
||||
/// know what a tunnel is, in which case the reason says <see cref="UnknownStreamKind"/>.
|
||||
/// </summary>
|
||||
public const int TargetUnreachableCloseCode = 4502;
|
||||
|
||||
/// <summary>Close code for an agent that disconnected from Spelunk while the tunnel was open.</summary>
|
||||
public const int AgentDisconnectedCloseCode = 4503;
|
||||
|
||||
/// <summary>
|
||||
/// What an agent that predates tunnels — or, asked to sign in, predates that — says, inside a
|
||||
/// <see cref="TargetUnreachableCloseCode"/>.
|
||||
/// </summary>
|
||||
internal const string UnknownStreamKind = "Unknown stream kind";
|
||||
|
||||
/// <summary>Where a ticket for one server is asked for.</summary>
|
||||
internal static string TicketsPath(Guid serverId) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"/api/servers/{serverId}/tunnel-tickets");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens SSH connections to Spelunk-managed servers through Spelunk.
|
||||
/// </summary>
|
||||
public interface ISpelunkTunnel
|
||||
{
|
||||
/// <summary>
|
||||
/// Opens a tunnel to one server, ready for a connection to be made through it.
|
||||
/// </summary>
|
||||
/// <param name="apiBase">The Spelunk API's base address, normalised by <see cref="SpelunkRoute"/>.</param>
|
||||
/// <param name="serverId">The server's id in Spelunk.</param>
|
||||
/// <param name="apiKey">The Spelunk API key, which authorises the ticket and is sent nowhere else.</param>
|
||||
/// <param name="port">
|
||||
/// The port the agent is to connect to on the server's own loopback. Not sent when signing in, where the
|
||||
/// agent starts an SSH server of its own and Spelunk ignores it.
|
||||
/// </param>
|
||||
/// <param name="signIn">
|
||||
/// The throwaway key to have Spelunk sign in with, or null for a plain tunnel to <paramref name="port"/>.
|
||||
/// Its public half goes with the ticket request; the route hands the whole of it back, with the account
|
||||
/// Spelunk named, for the connection made through it.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation.</param>
|
||||
/// <remarks>
|
||||
/// Asks for a ticket, opens the socket and starts the bridge, in that order and at once — the ticket is
|
||||
/// spent by the socket and dead thirty seconds after issue, so this is called by the connect that will
|
||||
/// use it and never ahead of one. The key is a parameter rather than a field of some request record
|
||||
/// because a record prints its fields, and a key has no business in anything that might be logged.
|
||||
/// </remarks>
|
||||
/// <exception cref="SpelunkTunnelException">The tunnel could not be opened; the message says why.</exception>
|
||||
Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An open Spelunk tunnel, one connection long.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same ownership as <see cref="RelayRoute"/>, because it is the same bridge: see
|
||||
/// <see cref="IConnectionRoute"/>.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkTunnelRoute : IConnectionRoute
|
||||
{
|
||||
/// <summary>The longest stretch of Spelunk's own close reason that is repeated to the user.</summary>
|
||||
private const int MaxReasonLength = 200;
|
||||
|
||||
private readonly RelayBridge bridge;
|
||||
private readonly int port;
|
||||
|
||||
internal SpelunkTunnelRoute(RelayBridge bridge, int port, SpelunkSignIn? signIn = null, string? user = null)
|
||||
{
|
||||
this.bridge = bridge;
|
||||
this.port = port;
|
||||
SignIn = signIn;
|
||||
User = signIn is null ? null : user ?? SpelunkSignIn.DefaultUser;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public SshLoopbackProxy Proxy => bridge.Proxy;
|
||||
|
||||
/// <summary>The key Spelunk was asked to sign in with, or null for a plain tunnel.</summary>
|
||||
/// <remarks>
|
||||
/// Handed back rather than kept by whoever asked, so the connection made through this route takes its
|
||||
/// credential from the same place it takes its proxy, and cannot be given one without the other.
|
||||
/// </remarks>
|
||||
public SpelunkSignIn? SignIn { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The account Spelunk signs in as — its answer's <c>user</c>, or <see cref="SpelunkSignIn.DefaultUser"/>
|
||||
/// when it named none — or null for a plain tunnel.
|
||||
/// </summary>
|
||||
public string? User { get; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A normal close is not a failure worth rewording: it is the SSH server hanging up, and SSH.NET's own
|
||||
/// account of that is the accurate one.
|
||||
/// </remarks>
|
||||
public string? Failure =>
|
||||
bridge.CloseStatus is { } status
|
||||
? Describe((int)status, bridge.CloseDescription, port, signingIn: SignIn is not null)
|
||||
: null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync() => bridge.DisposeAsync();
|
||||
|
||||
/// <summary>What a close code from Spelunk means, for someone who has to do something about it.</summary>
|
||||
internal static string? Describe(int code, string? reason, int port, bool signingIn = false) => code switch
|
||||
{
|
||||
SpelunkTunnelProtocol.TargetUnreachableCloseCode
|
||||
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
|
||||
signingIn
|
||||
? "The Spelunk agent on this server is too old to sign you in. Update the agent, then try again."
|
||||
: "The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
|
||||
|
||||
// Signing in, the agent connects to nothing: it starts an SSH server of its own, and what it could not
|
||||
// do is that — most often because the server has no OpenSSH server to start, which Spelunk's reason
|
||||
// says in its own words.
|
||||
SpelunkTunnelProtocol.TargetUnreachableCloseCode when signingIn =>
|
||||
"The Spelunk agent could not sign you in on this server"
|
||||
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
|
||||
+ ". Signing in through Spelunk needs OpenSSH's server (sshd) installed there.",
|
||||
|
||||
SpelunkTunnelProtocol.TargetUnreachableCloseCode =>
|
||||
string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"The Spelunk agent could not connect to port {port} on the server itself")
|
||||
+ (Readable(reason) is { } said ? $" ({said})" : string.Empty)
|
||||
+ ". Check that the SSH server is running and listening on that port.",
|
||||
|
||||
SpelunkTunnelProtocol.AgentDisconnectedCloseCode =>
|
||||
"The Spelunk agent on this server disconnected from Spelunk. It may be restarting; try again in a "
|
||||
+ "moment.",
|
||||
|
||||
(int)WebSocketCloseStatus.EndpointUnavailable => "Spelunk is restarting. Try again in a moment.",
|
||||
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk's own words, cut to one line of printable text, or null when there are none worth showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shown because they are specific — "connection refused" and "no route to host" are different problems
|
||||
/// on the server — and trimmed because they are another program's output going onto a status line.
|
||||
/// </remarks>
|
||||
internal static string? Readable(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var builder = new StringBuilder(Math.Min(text.Length, MaxReasonLength));
|
||||
|
||||
foreach (var character in text.Trim())
|
||||
{
|
||||
if (builder.Length == MaxReasonLength)
|
||||
{
|
||||
builder.Append('…');
|
||||
break;
|
||||
}
|
||||
|
||||
builder.Append(char.IsControl(character) ? ' ' : character);
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A Spelunk tunnel that could not be opened, with the reason in its message.</summary>
|
||||
/// <remarks>
|
||||
/// Its own type for the reason <see cref="ServerRelayException"/> is one: the connect path must be able to
|
||||
/// tell a refusal, which already says exactly what happened, from a connection failure it might explain by
|
||||
/// probing — and probing a Spelunk host would dial an address that exists only to name its key.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkTunnelException : Exception
|
||||
{
|
||||
/// <summary>A refusal with nothing underneath it.</summary>
|
||||
public SpelunkTunnelException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A refusal and what caused it.</summary>
|
||||
public SpelunkTunnelException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A refusal Spelunk answered with a status.</summary>
|
||||
public SpelunkTunnelException(string message, HttpStatusCode statusCode)
|
||||
: base(message) =>
|
||||
StatusCode = statusCode;
|
||||
|
||||
/// <summary>What Spelunk answered with, or null where the refusal came from somewhere else.</summary>
|
||||
/// <remarks>Kept for tests and logs; the screen reads the message.</remarks>
|
||||
public HttpStatusCode? StatusCode { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk tunnel: a ticket from the API, a socket, and the relay's bridge.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every way this can fail is turned into a sentence here, as <see cref="ServerRelay"/> does for the relay,
|
||||
/// because the status codes are Spelunk's and the one place that should know what each means is the one
|
||||
/// that asked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing from Spelunk's answer is trusted to go anywhere else.</b> The socket is opened on the API's
|
||||
/// own scheme, host and port, with only the path taken from the answer — and only a path that is one — so a
|
||||
/// ticket response cannot send this client, or the ticket, to a different machine.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SpelunkTunnel : ISpelunkTunnel
|
||||
{
|
||||
/// <summary>The longest ticket accepted. Far beyond Spelunk's; a bound on what goes into a header.</summary>
|
||||
private const int MaxTicketLength = 2048;
|
||||
|
||||
/// <summary>The longest account name taken from Spelunk's answer — well past what a Unix system allows.</summary>
|
||||
private const int MaxUserLength = 64;
|
||||
|
||||
/// <summary>The longest error body read, so a misbehaving proxy cannot hand this a gigabyte.</summary>
|
||||
private const int MaxProblemBytes = 16 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// One transport for the process, as the platform advises for <see cref="HttpClient"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bounded well short of the default hundred seconds: a ticket is one small request, and somebody
|
||||
/// watching "Connecting…" should hear that Spelunk is not answering while they still care.
|
||||
/// </remarks>
|
||||
private static readonly HttpClient SharedHttp = new(
|
||||
new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30),
|
||||
};
|
||||
|
||||
private readonly HttpClient http;
|
||||
private readonly Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect;
|
||||
|
||||
/// <summary>A tunnel over the process's shared transport, which is how the application builds one.</summary>
|
||||
public SpelunkTunnel()
|
||||
: this(SharedHttp, DialAsync)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>A tunnel whose requests and socket are supplied, for tests.</summary>
|
||||
internal SpelunkTunnel(
|
||||
HttpClient http,
|
||||
Func<Uri, IReadOnlyList<string>, CancellationToken, Task<WebSocket>> connect)
|
||||
{
|
||||
this.http = http;
|
||||
this.connect = connect;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!SpelunkRoute.TryNormalizeApiBase(apiBase, out var normalized, out var reason))
|
||||
{
|
||||
throw new SpelunkTunnelException(reason);
|
||||
}
|
||||
|
||||
var ticket = await IssueTicketAsync(normalized, serverId, Sanitise(apiKey), port, signIn, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var socket = await connect(
|
||||
SocketUrl(normalized, ticket.Path!),
|
||||
[SpelunkTunnelProtocol.SubProtocol, SpelunkTunnelProtocol.TicketProtocolPrefix + ticket.Ticket],
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(socket), port, signIn, ticket.User);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the tunnel socket is: the API's own origin, on the WebSocket scheme, at the path Spelunk named.
|
||||
/// </summary>
|
||||
internal static Uri SocketUrl(string apiBase, string path)
|
||||
{
|
||||
var secure = apiBase.StartsWith("https://", StringComparison.Ordinal);
|
||||
var rest = apiBase[(secure ? "https".Length : "http".Length)..];
|
||||
|
||||
return new Uri((secure ? "wss" : "ws") + rest + path, UriKind.Absolute);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trims the stored key and refuses one that could not be a header value.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A key pasted into a credential arrives with a newline behind it often enough to be worth handling, and
|
||||
/// one with a space in the middle would otherwise surface as a <see cref="FormatException"/> from inside
|
||||
/// <see cref="HttpClient"/> rather than as a sentence about the credential.
|
||||
/// </remarks>
|
||||
private static string Sanitise(string apiKey)
|
||||
{
|
||||
var trimmed = apiKey?.Trim() ?? string.Empty;
|
||||
|
||||
if (trimmed.Length == 0 || trimmed.Any(character => char.IsWhiteSpace(character) || char.IsControl(character)))
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
"The credential chosen for the Spelunk API key does not hold one. A Spelunk API key is one "
|
||||
+ "unbroken run of characters starting with spelunk_; paste it as the credential's password.");
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private async Task<SpelunkTicketDocument> IssueTicketAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Post,
|
||||
new Uri(apiBase + SpelunkTunnelProtocol.TicketsPath(serverId), UriKind.Absolute));
|
||||
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
|
||||
// Serialised up front rather than streamed, so the request carries a Content-Length: a dozen bytes
|
||||
// do not need chunked encoding, and not every proxy in front of an API accepts it.
|
||||
//
|
||||
// Signing in sends the public key and no port: the agent starts an SSH server of its own rather than
|
||||
// connecting to one, and Spelunk ignores the port beside a key anyway.
|
||||
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
||||
signIn is null
|
||||
? new SpelunkTicketRequestDocument { Port = port }
|
||||
: new SpelunkTicketRequestDocument
|
||||
{
|
||||
SignIn = new SpelunkSignInRequestDocument { PublicKey = signIn.PublicKeyLine },
|
||||
},
|
||||
SpelunkTunnelJsonContext.Default.SpelunkTicketRequestDocument))
|
||||
{
|
||||
Headers = { ContentType = new MediaTypeHeaderValue("application/json") },
|
||||
};
|
||||
|
||||
HttpResponseMessage response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
$"Spelunk could not be reached at {apiBase}: {exception.Message}",
|
||||
exception);
|
||||
}
|
||||
catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
$"Spelunk at {apiBase} did not answer in time. Try again in a moment.",
|
||||
exception);
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var problem = await ReadProblemAsync(response, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
throw new SpelunkTunnelException(Describe(response.StatusCode, problem), response.StatusCode);
|
||||
}
|
||||
|
||||
return await ReadTicketAsync(response, signingIn: signIn is not null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the ticket, and refuses one this client would have to take on trust.</summary>
|
||||
private static async Task<SpelunkTicketDocument> ReadTicketAsync(
|
||||
HttpResponseMessage response,
|
||||
bool signingIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SpelunkTicketDocument? ticket;
|
||||
|
||||
try
|
||||
{
|
||||
ticket = await response.Content
|
||||
.ReadFromJsonAsync(SpelunkTunnelJsonContext.Default.SpelunkTicketDocument, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
throw new SpelunkTunnelException(Unintelligible, exception);
|
||||
}
|
||||
|
||||
// The ticket goes into a header as part of a subprotocol name, which may hold only token characters —
|
||||
// base64url is all of those, and anything else would fail inside the WebSocket client instead. The
|
||||
// path is appended to the API's own origin, so it has to be a path: "//elsewhere" or "@elsewhere"
|
||||
// would turn it into a different host.
|
||||
if (ticket is not { Ticket: { Length: > 0 and <= MaxTicketLength } value, Path: { } path }
|
||||
|| !value.All(IsBase64UrlCharacter)
|
||||
|| !path.StartsWith('/')
|
||||
|| path.StartsWith("//", StringComparison.Ordinal)
|
||||
|| path.Any(character => character is '?' or '#' or '\\' or '@' || char.IsWhiteSpace(character) || char.IsControl(character))
|
||||
|| (ticket.Subprotocol is { } named
|
||||
&& !string.Equals(named, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|
||||
|| (signingIn && ticket.User is { } user && !IsAccountName(user)))
|
||||
{
|
||||
throw new SpelunkTunnelException(Unintelligible);
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private const string Unintelligible =
|
||||
"Spelunk answered the tunnel request in a way this app does not understand. Update DodoSSH, or check "
|
||||
+ "that the API address is Spelunk's.";
|
||||
|
||||
private static bool IsBase64UrlCharacter(char character) =>
|
||||
char.IsAsciiLetterOrDigit(character) || character is '-' or '_';
|
||||
|
||||
/// <summary>
|
||||
/// Whether the account Spelunk named is one an SSH login could be for: a short run of the characters
|
||||
/// Unix account names are made of.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Checked because it goes to the server as the login and onto the tab and the status line, and an
|
||||
/// answer carrying anything else is not one this client understands.
|
||||
/// </remarks>
|
||||
private static bool IsAccountName(string user) =>
|
||||
user.Length is > 0 and <= MaxUserLength
|
||||
&& user.All(character => char.IsAsciiLetterOrDigit(character) || character is '_' or '-' or '.');
|
||||
|
||||
/// <summary>What a refused ticket means, in the words of the status and of Spelunk's own detail.</summary>
|
||||
private static string Describe(HttpStatusCode status, SpelunkProblemDocument? problem)
|
||||
{
|
||||
var detail = SpelunkTunnelRoute.Readable(problem?.Detail);
|
||||
|
||||
return status switch
|
||||
{
|
||||
// Spelunk's validation says which field and why; for a sign-in it is the public key.
|
||||
HttpStatusCode.BadRequest =>
|
||||
"Spelunk would not accept the request to open the tunnel"
|
||||
+ ((SpelunkTunnelRoute.Readable(PublicKeyError(problem))
|
||||
?? detail
|
||||
?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said
|
||||
? $": {said}"
|
||||
: ". Update DodoSSH, or check that the API address is Spelunk's."),
|
||||
|
||||
HttpStatusCode.Unauthorized =>
|
||||
"Spelunk did not accept the API key. It may have been revoked or mistyped — create a new one in "
|
||||
+ "Spelunk and paste it into the credential this host uses for it.",
|
||||
|
||||
// The detail is the useful half here: it says which role the key has and which it needs.
|
||||
HttpStatusCode.Forbidden =>
|
||||
"The Spelunk API key is not allowed to open a terminal on this server"
|
||||
+ (detail is null ? ". Opening one needs a key with the Admin role." : $": {detail}"),
|
||||
|
||||
HttpStatusCode.NotFound =>
|
||||
"Spelunk does not know this server in the API key's organisation. It may have been removed, or "
|
||||
+ "the key belongs to a different organisation.",
|
||||
|
||||
HttpStatusCode.ServiceUnavailable =>
|
||||
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it. Check that the "
|
||||
+ "agent is running on the server, then try again.",
|
||||
|
||||
_ => string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"Spelunk would not open the tunnel (HTTP {(int)status})")
|
||||
+ ((detail ?? SpelunkTunnelRoute.Readable(problem?.Title)) is { } said ? $": {said}" : "."),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>What a validation problem says about the sign-in key, if it says anything.</summary>
|
||||
/// <remarks>
|
||||
/// Matched on the end of the name and without regard to case, because a validator may name the field
|
||||
/// <c>publicKey</c>, <c>PublicKey</c> or <c>signIn.publicKey</c>, and all three mean this one.
|
||||
/// </remarks>
|
||||
private static string? PublicKeyError(SpelunkProblemDocument? problem) =>
|
||||
problem?.Errors?
|
||||
.FirstOrDefault(field => field.Key.EndsWith("publicKey", StringComparison.OrdinalIgnoreCase))
|
||||
.Value?
|
||||
.FirstOrDefault();
|
||||
|
||||
/// <summary>Reads an RFC 7807 body if there is one; null when there is not, or it will not parse.</summary>
|
||||
private static async Task<SpelunkProblemDocument?> ReadProblemAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var body = await response.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return body.Length is 0 or > MaxProblemBytes
|
||||
? null
|
||||
: JsonSerializer.Deserialize(body, SpelunkTunnelJsonContext.Default.SpelunkProblemDocument);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens the tunnel socket, and translates a refused upgrade into its reason.</summary>
|
||||
/// <remarks>
|
||||
/// The status of a refused upgrade is collected, as the relay's dialler does. A 401 is a ticket Spelunk
|
||||
/// would not take — spent, or expired on the way — and asking again is the remedy; a 503 is the agent
|
||||
/// going away in the moment between the ticket and the socket.
|
||||
/// </remarks>
|
||||
private static async Task<WebSocket> DialAsync(
|
||||
Uri url,
|
||||
IReadOnlyList<string> protocols,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var socket = new ClientWebSocket();
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var protocol in protocols)
|
||||
{
|
||||
socket.Options.AddSubProtocol(protocol);
|
||||
}
|
||||
|
||||
socket.Options.CollectHttpResponseDetails = true;
|
||||
|
||||
await socket.ConnectAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (WebSocketException exception)
|
||||
{
|
||||
var status = socket.HttpStatusCode;
|
||||
socket.Dispose();
|
||||
|
||||
throw new SpelunkTunnelException(
|
||||
status switch
|
||||
{
|
||||
HttpStatusCode.Unauthorized => "Spelunk refused the tunnel ticket. Try connecting again.",
|
||||
HttpStatusCode.ServiceUnavailable => Describe(status, problem: null),
|
||||
_ => $"The Spelunk tunnel could not be opened: {exception.Message}",
|
||||
},
|
||||
exception);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!string.Equals(socket.SubProtocol, SpelunkTunnelProtocol.SubProtocol, StringComparison.Ordinal))
|
||||
{
|
||||
socket.Abort();
|
||||
socket.Dispose();
|
||||
|
||||
throw new SpelunkTunnelException(
|
||||
"Spelunk answered the tunnel in a protocol this app does not speak. Update DodoSSH or Spelunk.");
|
||||
}
|
||||
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The body of a ticket request.</summary>
|
||||
/// <remarks>
|
||||
/// Either a port or a sign-in, never both: the one not asked for is null and left out of the JSON, so a plain
|
||||
/// tunnel still asks for exactly <c>{"port":22}</c>.
|
||||
/// </remarks>
|
||||
internal sealed class SpelunkTicketRequestDocument
|
||||
{
|
||||
public int? Port { get; set; }
|
||||
|
||||
public SpelunkSignInRequestDocument? SignIn { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>What a sign-in sends: the one-line OpenSSH public key the agent's SSH server is to accept.</summary>
|
||||
internal sealed class SpelunkSignInRequestDocument
|
||||
{
|
||||
public string? PublicKey { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A ticket as Spelunk returns it. Nullable because it models untrusted input.</summary>
|
||||
internal sealed class SpelunkTicketDocument
|
||||
{
|
||||
public string? Ticket { get; set; }
|
||||
|
||||
public DateTimeOffset? ExpiresAt { get; set; }
|
||||
|
||||
public string? Path { get; set; }
|
||||
|
||||
public string? Subprotocol { get; set; }
|
||||
|
||||
/// <summary>The account a sign-in logs in as; absent or null for a plain tunnel.</summary>
|
||||
public string? User { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The fields of an RFC 7807 problem this client repeats.</summary>
|
||||
internal sealed class SpelunkProblemDocument
|
||||
{
|
||||
public string? Title { get; set; }
|
||||
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>A validation problem's messages by field, as ASP.NET Core writes them.</summary>
|
||||
public Dictionary<string, string[]>? Errors { get; set; }
|
||||
}
|
||||
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
|
||||
[JsonSerializable(typeof(SpelunkTicketRequestDocument))]
|
||||
[JsonSerializable(typeof(SpelunkTicketDocument))]
|
||||
[JsonSerializable(typeof(SpelunkProblemDocument))]
|
||||
internal sealed partial class SpelunkTunnelJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,61 @@
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// The shell's half of a link from Spelunk: holding it until there is a vault to act on it.
|
||||
/// </summary>
|
||||
internal sealed partial class MainWindowViewModel
|
||||
{
|
||||
/// <summary>
|
||||
/// Acts on a <c>dodossh://spelunk/connect</c> link now, or as soon as a vault is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Called by the head, on the UI thread, for a link this launch was started with, a link a second launch
|
||||
/// passed down the doorbell, or one the platform delivered to the running application. The link has
|
||||
/// already been checked by <see cref="SpelunkLink.TryParse"/>; what it may do is decided by
|
||||
/// <see cref="VaultViewModel.OpenSpelunkLinkAsync"/>, which never sends an API key to a Spelunk no saved
|
||||
/// host already sends it to.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
||||
/// asked for the passphrase is the ordinary first half of this; losing the click at the second half would
|
||||
/// make the button look broken. It is not kept across a restart.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void OpenLink(SpelunkLink link)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(link);
|
||||
|
||||
pendingLink = link;
|
||||
|
||||
if (State is ShellState.Unlocked && Vault is not null)
|
||||
{
|
||||
DeliverPendingLink();
|
||||
return;
|
||||
}
|
||||
|
||||
if (State is ShellState.Locked)
|
||||
{
|
||||
StatusMessage = $"Unlock to open {link.Name} from Spelunk.";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Hands a waiting link to the open vault, on the Hosts screen where its answer appears.</summary>
|
||||
private void DeliverPendingLink()
|
||||
{
|
||||
if (pendingLink is not { } link || Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLink = null;
|
||||
|
||||
ShowScreen(ShellScreen.Hosts);
|
||||
|
||||
// Not awaited: a connect reports its own progress and failures on the vault's status line and in its
|
||||
// tab, as a tap on the row does, and this is called from places that must not wait on a handshake.
|
||||
_ = vault.OpenSpelunkLinkAsync(link);
|
||||
}
|
||||
}
|
||||
@@ -360,6 +360,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly ISshReachabilityProbe? reachability;
|
||||
|
||||
/// <summary>What opens a Spelunk tunnel, handed to each vault this shell opens.</summary>
|
||||
/// <remarks>
|
||||
/// Held for the reason <see cref="reachability"/> is: the vault is built on unlock, not here. Null means
|
||||
/// the real one, which is what both heads use; a test passes one so a Spelunk host's connect never leaves
|
||||
/// the process.
|
||||
/// </remarks>
|
||||
private readonly ISpelunkTunnel? spelunk;
|
||||
|
||||
/// <summary>
|
||||
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One, and the latest: a link is somebody pressing a button in Spelunk, and a second press while this
|
||||
/// machine is still locked means the second server, not both. Delivered as soon as a vault opens, and
|
||||
/// dropped if the application closes first — it is a request to do something now, not something to
|
||||
/// remember.
|
||||
/// </remarks>
|
||||
private SpelunkLink? pendingLink;
|
||||
|
||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||
private readonly Action<Action> post;
|
||||
|
||||
@@ -569,9 +588,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Action<Action>? post = null,
|
||||
Func<string, Task>? openInEditor = null,
|
||||
Func<string, Action, IDisposable>? watchFile = null,
|
||||
ISshReachabilityProbe? reachability = null)
|
||||
ISshReachabilityProbe? reachability = null,
|
||||
ISpelunkTunnel? spelunk = null)
|
||||
{
|
||||
this.reachability = reachability;
|
||||
this.spelunk = spelunk;
|
||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||
|
||||
this.paths = paths;
|
||||
@@ -2938,7 +2959,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
copyToClipboard,
|
||||
connectionLog,
|
||||
visibility,
|
||||
reachability);
|
||||
reachability,
|
||||
spelunk);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -2954,19 +2976,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// After the load, because the switches are built from the vaults the session admitted and the
|
||||
// keyring is filled during it — before, and a machine with a team vault would come up with one
|
||||
// switch until something else rebuilt them.
|
||||
RebuildVaultToggles();
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||
|
||||
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
|
||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
||||
// second old.
|
||||
RefreshConnectedHosts();
|
||||
AfterTheFirstLoad(Vault);
|
||||
|
||||
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
||||
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
||||
@@ -2984,6 +2994,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Vault.StartAutoSync();
|
||||
}
|
||||
|
||||
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
|
||||
/// <remarks>
|
||||
/// Split out of <see cref="AdoptAsync"/> for length rather than for reuse; the order is still the one that
|
||||
/// method's remarks call load-bearing, and it is kept here as it was there.
|
||||
/// </remarks>
|
||||
private void AfterTheFirstLoad(VaultViewModel vault)
|
||||
{
|
||||
// After the load, because the switches are built from the vaults the session admitted and the
|
||||
// keyring is filled during it — before, and a machine with a team vault would come up with one
|
||||
// switch until something else rebuilt them.
|
||||
RebuildVaultToggles();
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||
|
||||
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
|
||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
||||
// second old.
|
||||
RefreshConnectedHosts();
|
||||
|
||||
// After the hosts are loaded, because what a link does depends on whether one of them is already
|
||||
// the server it names.
|
||||
DeliverPendingLink();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the two process-lifetime stores at the session that has just opened.
|
||||
/// </summary>
|
||||
|
||||
@@ -1158,7 +1158,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
ShowsHostPicker
|
||||
&& (SelectedHost is null
|
||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword));
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
@@ -1796,14 +1797,14 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
if (await OpenHostSessionAsync(open, row, request, cancellationToken).ConfigureAwait(true)
|
||||
is not { } opened)
|
||||
is not ({ } opened, { } dialled))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
session = opened;
|
||||
TypedPassword = string.Empty;
|
||||
MarkHostConnected(opened, request, row, identityLabel);
|
||||
MarkHostConnected(opened, dialled, row, identityLabel);
|
||||
|
||||
reconnectTo = (RemoteKind.Host, row.EntityId);
|
||||
|
||||
@@ -1816,34 +1817,36 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the session on a host, through the relay when the host uses it, or says why not.
|
||||
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
|
||||
/// </summary>
|
||||
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
|
||||
private async Task<ISftpSession?> OpenHostSessionAsync(
|
||||
/// <returns>
|
||||
/// The session and the request it was opened with — which, for a Spelunk sign-in, carries the account
|
||||
/// Spelunk named rather than the one the host resolved to — or null with <see cref="Status"/> saying why
|
||||
/// there is none.
|
||||
/// </returns>
|
||||
private async Task<(ISftpSession Session, SshConnectionRequest Request)?> OpenHostSessionAsync(
|
||||
VaultViewModel open,
|
||||
HostRowViewModel row,
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
RelayRoute? relay = null;
|
||||
IConnectionRoute? route = null;
|
||||
|
||||
try
|
||||
{
|
||||
// Through the relay for a host set to use it, exactly as a shell to it would be. The
|
||||
// session owns the relay from here, on success and on failure alike.
|
||||
relay = row.Host.RelayEnabled
|
||||
? await open.OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
|
||||
: null;
|
||||
// Through the relay or Spelunk for a host set to use one, exactly as a shell to it would be.
|
||||
// The session owns the route from here, on success and on failure alike.
|
||||
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return await sftp
|
||||
.OpenSftpAsync(relay is null ? request : request with { Proxy = relay.Proxy }, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
var through = VaultViewModel.Through(request, route);
|
||||
|
||||
return (await sftp.OpenSftpAsync(through, cancellationToken).ConfigureAwait(true), through);
|
||||
}
|
||||
catch (Exception exception)
|
||||
when (relay?.Failure is { } failure
|
||||
when (route?.Failure is { } failure
|
||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||
{
|
||||
// See VaultViewModel.OpenThroughAsync: the relay's reason is the true one.
|
||||
// See VaultViewModel.OpenThroughAsync: the route's reason is the true one.
|
||||
Status = failure;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Reaching a host through Spelunk: the editor's half, the connect path's half, and a link from Spelunk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A file of its own because it is one feature and this class is long enough without it. Everything here
|
||||
/// follows the relay, which is the other route a host can take: a tick box in the editor, a route opened by
|
||||
/// the connect that will use it, no falling back to a direct dial, and a stated failure when the route
|
||||
/// cannot be opened. See <see cref="HostSecret.Spelunk"/> and <see cref="SpelunkRoute"/>.
|
||||
/// </remarks>
|
||||
internal sealed partial class VaultViewModel
|
||||
{
|
||||
/// <summary>The tag every host made from a Spelunk link wears, and what a link looks for.</summary>
|
||||
/// <remarks>
|
||||
/// Matched without regard to case, as the editor matches every tag name — see <c>AddEditorTagAsync</c>.
|
||||
/// </remarks>
|
||||
internal const string SpelunkTag = "spelunk";
|
||||
|
||||
/// <summary>What the status bar and the log name as the identity of a Spelunk sign-in.</summary>
|
||||
private const string SpelunkSignInIdentity = "Spelunk sign-in";
|
||||
|
||||
/// <summary>What opens a tunnel when no caller supplied something else.</summary>
|
||||
/// <remarks>
|
||||
/// Static and shared for the reason <see cref="DefaultReachability"/> is: it holds nothing but a
|
||||
/// transport, and every tunnel is a fresh ticket and a fresh socket. Optional in the constructor so both
|
||||
/// heads and every existing test go on meaning what they did, and a test that wants to see what was
|
||||
/// opened passes a substitute.
|
||||
/// </remarks>
|
||||
private static readonly ISpelunkTunnel DefaultSpelunk = new SpelunkTunnel();
|
||||
|
||||
/// <summary>Whether the host being edited is reached through Spelunk.</summary>
|
||||
/// <remarks>
|
||||
/// Ticking it unticks the relay and the other way round, rather than letting both be ticked and refusing
|
||||
/// the save: a host has one route, and <see cref="HostSecret.TryValidate"/> would only catch after the
|
||||
/// fact what these two boxes can simply not express.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
|
||||
private bool editorThroughSpelunk;
|
||||
|
||||
/// <summary>Whether Spelunk signs the user in to the host being edited, so it needs no login of its own.</summary>
|
||||
/// <remarks>
|
||||
/// Inside the Spelunk card, and meaningful only while that is ticked. See
|
||||
/// <see cref="SpelunkRoute.SignInThroughSpelunk"/>.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(EditorAsksForLogin))]
|
||||
private bool editorSpelunkSignIn;
|
||||
|
||||
/// <summary>The Spelunk API address in the editor, as typed.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorSpelunkApi = string.Empty;
|
||||
|
||||
/// <summary>The Spelunk server id in the editor, as typed.</summary>
|
||||
[ObservableProperty]
|
||||
private string editorSpelunkServerId = string.Empty;
|
||||
|
||||
/// <summary>The credentials the editor offers as the Spelunk API key.</summary>
|
||||
/// <remarks>
|
||||
/// Credentials only, and no "none" entry: a route with no key cannot be opened, and nothing selected is
|
||||
/// how a host made from a link says it has not been chosen yet. A key the host names that has since been
|
||||
/// deleted keeps a placeholder, for the reason <see cref="BuildAuthenticationChoices"/> keeps one.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<AuthenticationChoice> EditorSpelunkKeyChoices { get; } = [];
|
||||
|
||||
/// <summary>The credential chosen to hold the Spelunk API key.</summary>
|
||||
[ObservableProperty]
|
||||
private AuthenticationChoice? editorSelectedSpelunkKey;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the editor's username and login choice mean anything — false for a host Spelunk signs in to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The two controls are disabled rather than hidden while it is false, with a sentence saying why, so a
|
||||
/// login somebody had chosen is still there to see — and comes back as it was if the box is unticked
|
||||
/// before saving.
|
||||
/// </remarks>
|
||||
internal bool EditorAsksForLogin => !(EditorThroughSpelunk && EditorSpelunkSignIn);
|
||||
|
||||
/// <inheritdoc cref="DefaultSpelunk" />
|
||||
private ISpelunkTunnel Spelunk => spelunk ?? DefaultSpelunk;
|
||||
|
||||
partial void OnEditorThroughSpelunkChanged(bool value)
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
EditorRelayEnabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnEditorRelayEnabledChanged(bool value)
|
||||
{
|
||||
if (value)
|
||||
{
|
||||
EditorThroughSpelunk = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the route a host is reached by, or null for a host that is dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one place that decides, for both transports: a shell comes through here from
|
||||
/// <see cref="OpenThroughAsync"/>, and file transfer opens its own connection and comes through here from
|
||||
/// the transfers screen — or that screen would fail for exactly the hosts a route exists for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A host naming a route that cannot be opened is a refusal, never a quiet direct dial. See
|
||||
/// <see cref="OpenRelayAsync"/> for the relay's half of that argument; Spelunk's is stronger still,
|
||||
/// because a Spelunk host's address may be nothing that answers at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The request a route is used for is made with <see cref="Through"/>, which is where a Spelunk sign-in's
|
||||
/// key and account replace the ones the host resolved to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<IConnectionRoute?> OpenRouteAsync(HostRowViewModel row, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(row);
|
||||
|
||||
if (row.Host.Spelunk is { } route)
|
||||
{
|
||||
return await OpenSpelunkTunnelAsync(row, route, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
return row.Host.RelayEnabled
|
||||
? await OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
|
||||
: null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The request to connect with once a route is open: through its proxy, and for a Spelunk sign-in, with
|
||||
/// the key the tunnel was opened with and the account Spelunk named.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both transports make their request through this, so a sign-in cannot reach one of them with the
|
||||
/// stand-in credential <see cref="TryBuildAuthentication"/> gives it — which the SSH layer would refuse
|
||||
/// before dialling anyway. See <see cref="SpelunkSignInPending"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The host and port are left alone. The host key is pinned under them as for every host, and Spelunk's
|
||||
/// SSH server presents the server's real host key, so a sign-in and a plain tunnel to one server are
|
||||
/// checked against the same pin.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static SshConnectionRequest Through(SshConnectionRequest request, IConnectionRoute? route)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return route switch
|
||||
{
|
||||
null => request,
|
||||
SpelunkTunnelRoute { SignIn: { } signIn, User: { } user } => request with
|
||||
{
|
||||
Proxy = route.Proxy,
|
||||
Username = user,
|
||||
Credential = signIn.Credential,
|
||||
},
|
||||
_ => request with { Proxy = route.Proxy },
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens a Spelunk tunnel to a host, with the key its credential holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The key is read from the keychain at the moment of connecting, like every other secret the connect
|
||||
/// path uses, and goes only to the API address the host names. A credential that has gone is refused in
|
||||
/// words, exactly as a missing SSH credential is — see <see cref="TryBuildAuthentication"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The port is the host's resolved one, group and all: it is the port the agent connects to on the
|
||||
/// server's own loopback, which is where the SSH server listens whatever address the world sees.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A host Spelunk signs in to gets a new key pair here, every time.</b> It is made in memory for this
|
||||
/// one tunnel, its public half goes with the ticket request, and the route carries it to the connection —
|
||||
/// see <see cref="SpelunkSignIn"/>. A retry after a host key question, or a reconnect, comes back through
|
||||
/// here and makes another.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<IConnectionRoute> OpenSpelunkTunnelAsync(
|
||||
HostRowViewModel row,
|
||||
SpelunkRoute route,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (Credentials.FirstOrDefault(credential => credential.EntityId == route.ApiKeyCredentialId) is not { } key)
|
||||
{
|
||||
throw new SpelunkTunnelException(
|
||||
$"'{row.Label}' is reached through Spelunk with an API key credential that is not in this "
|
||||
+ "keychain any more. Edit the host to choose another one.");
|
||||
}
|
||||
|
||||
return await Spelunk
|
||||
.OpenAsync(
|
||||
route.ApiBase,
|
||||
route.ServerId,
|
||||
key.Credential.Password,
|
||||
row.Resolved.Port.Value,
|
||||
route.SignInThroughSpelunk ? SpelunkSignIn.Create() : null,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// The work the link started, connection attempt included, so a caller that wants to can wait for it.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A host for that server in the personal vault, tagged <see cref="SpelunkTag"/>, is connected to</b>,
|
||||
/// exactly as a tap on its row would. Those are the hosts links make, and the only ones a link looks for:
|
||||
/// a host somebody set up by hand, or one in a team's vault, is theirs to connect to and not the link's.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Otherwise, when that Spelunk already gets an API key, the host is made and connected to.</b> In the
|
||||
/// personal vault, tagged, named as the link names it, and signing in through Spelunk — so it needs no
|
||||
/// SSH key or password — with the key credential a saved host already sends to that same Spelunk. It is
|
||||
/// saved the ordinary way and syncs like any host. Nothing leaks by this: the key goes where it already
|
||||
/// goes, and the server is one that key's own organisation holds or Spelunk refuses it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The first link from a Spelunk opens the editor instead, and nothing more.</b> Anything on this
|
||||
/// machine can make a link, so it must never be able to send an API key to an address no saved host
|
||||
/// already sends it to. The host is pre-filled — signing in through Spelunk, tagged, in the personal
|
||||
/// vault — and the user chooses the credential holding the key and saves; every later link from that
|
||||
/// Spelunk connects straight away.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(link);
|
||||
|
||||
var personal = PersonalVaultId();
|
||||
|
||||
if (personal is { } vaultId
|
||||
&& Hosts.FirstOrDefault(row =>
|
||||
row.VaultId == vaultId
|
||||
&& row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true
|
||||
&& WearsSpelunkTag(row.Host)) is { } saved)
|
||||
{
|
||||
await ConnectToRowCommand.ExecuteAsync(saved).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
var knownKey = KnownSpelunkKey(link.ApiBase, personal);
|
||||
|
||||
if (personal is { } into && knownKey is { } key)
|
||||
{
|
||||
await AddFromLinkAndConnectAsync(link, into, key).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await OpenLinkInEditorAsync(link, personal, knownKey).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>The personal vault, when this session can read and write it.</summary>
|
||||
private Guid? PersonalVaultId() =>
|
||||
session.ReadableVaults.FirstOrDefault(vault => vault.IsPersonal && vault.CanWrite)?.VaultId;
|
||||
|
||||
/// <summary>Whether a host wears a tag called <see cref="SpelunkTag"/>, from whichever vault.</summary>
|
||||
private bool WearsSpelunkTag(HostSecret host) =>
|
||||
host.TagIds.Any(id => tagsById.TryGetValue(id, out var tag) && IsSpelunkTag(tag));
|
||||
|
||||
private static bool IsSpelunkTag(TagSecret tag) =>
|
||||
string.Equals(tag.Label.Trim(), SpelunkTag, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// The credential a saved host already sends to this Spelunk as its API key, or null when none does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The personal vault's hosts first, so the answer does not depend on the order vaults happen to load in;
|
||||
/// and only a credential still in the keychain, because a dangling one would make a host that cannot
|
||||
/// connect.
|
||||
/// </remarks>
|
||||
private Guid? KnownSpelunkKey(string apiBase, Guid? personal) =>
|
||||
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));
|
||||
|
||||
/// <summary>The host a link makes: what the link says, signing in through Spelunk with a known key.</summary>
|
||||
private static HostSecret HostFromLink(SpelunkLink link, Guid apiKeyCredentialId, TagSet tags) => new()
|
||||
{
|
||||
Label = link.Name,
|
||||
Hostname = link.Hostname,
|
||||
Port = HostSecret.DefaultPort,
|
||||
TagIds = tags,
|
||||
Spelunk = new SpelunkRoute
|
||||
{
|
||||
ApiBase = link.ApiBase,
|
||||
ServerId = link.ServerId,
|
||||
ApiKeyCredentialId = apiKeyCredentialId,
|
||||
SignInThroughSpelunk = true,
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>Saves the host a link describes into the personal vault, tagged, and connects to it.</summary>
|
||||
private async Task AddFromLinkAndConnectAsync(SpelunkLink link, Guid vaultId, Guid apiKeyCredentialId)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
// RunAsync would drop it without a word, and the user pressed a button somewhere else to get here.
|
||||
Status = $"Busy — open {link.Name} from Spelunk again in a moment.";
|
||||
return;
|
||||
}
|
||||
|
||||
Guid? hostId = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Adding {link.Name} from Spelunk…",
|
||||
async () =>
|
||||
{
|
||||
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
hostId = await session.Hosts
|
||||
.CreateAsync(
|
||||
vaultId,
|
||||
HostFromLink(link, apiKeyCredentialId, TagSet.Create([tagId])),
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}).ConfigureAwait(true);
|
||||
|
||||
// A failure has said so on the status line already.
|
||||
if (hostId is not { } id || Hosts.FirstOrDefault(row => row.EntityId == id) is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Status = $"Added {link.Name} from Spelunk at {link.ApiBase}.";
|
||||
|
||||
await ConnectToRowCommand.ExecuteAsync(row).ConfigureAwait(true);
|
||||
|
||||
// After the connect rather than before it: the host is saved either way, and the terminal is what the
|
||||
// user is waiting for. As SaveHostAsync does, it goes to the server now rather than at the next tick.
|
||||
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the editor on a new host from a link, for the first link from a Spelunk no host sends a key to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Opened before anything is awaited, so the editor is up in the same turn the link arrived in. The tag is
|
||||
/// made or found afterwards and added to the host being edited; a tag made for a host that is then
|
||||
/// cancelled stays, as the editor's own new-tag box leaves it.
|
||||
/// </remarks>
|
||||
private async Task OpenLinkInEditorAsync(SpelunkLink link, Guid? personal, Guid? knownKey)
|
||||
{
|
||||
// Refused while another host is half-edited, as NewHost refuses it, and said the same way: the link
|
||||
// is not worth somebody's unsaved host.
|
||||
if (AHostEditorIsInTheWay())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
NewHost();
|
||||
|
||||
if (personal is { } vaultId
|
||||
&& EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId) is { } choice)
|
||||
{
|
||||
EditorSelectedVault = choice;
|
||||
}
|
||||
|
||||
EditorLabel = link.Name;
|
||||
EditorHostname = link.Hostname;
|
||||
EditorPort = HostSecret.DefaultPort;
|
||||
EditorThroughSpelunk = true;
|
||||
EditorSpelunkSignIn = true;
|
||||
EditorSpelunkApi = link.ApiBase;
|
||||
EditorSpelunkServerId = link.ServerId.ToString();
|
||||
|
||||
BuildSpelunkKeyChoices(knownKey);
|
||||
|
||||
IsHostPaneOpen = true;
|
||||
|
||||
var said = EditorSelectedSpelunkKey is null
|
||||
? $"Adding {link.Name}, the first server from Spelunk at {link.ApiBase}. Choose the credential "
|
||||
+ "holding your Spelunk API key and save — Spelunk signs you in, so no SSH key is needed, and "
|
||||
+ "later links from this Spelunk connect straight away."
|
||||
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Check it and save.";
|
||||
|
||||
Status = said;
|
||||
|
||||
if (personal is { } into)
|
||||
{
|
||||
await TagHostFromLinkAsync(into, said).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds the <see cref="SpelunkTag"/> tag to the host a link opened the editor on.</summary>
|
||||
/// <param name="vaultId">The personal vault, where the host is to be saved and the tag is.</param>
|
||||
/// <param name="said">What the status line says about the link, put back after the reload.</param>
|
||||
private async Task TagHostFromLinkAsync(Guid vaultId, string said)
|
||||
{
|
||||
try
|
||||
{
|
||||
var tagId = await EnsureSpelunkTagAsync(vaultId, CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
// Only onto the host this link opened: the user may have cancelled it, or moved on to another, in
|
||||
// the moment the tag took.
|
||||
if (!IsEditing || editingEntityId is not null || editingHostVaultId != vaultId)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
editorTagIds = TagSet.Create([.. editorTagIds, tagId]);
|
||||
|
||||
if (!tagsById.ContainsKey(tagId))
|
||||
{
|
||||
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
BuildTagChoices();
|
||||
Status = said;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The host can still be saved; it is the tag that could not be made, and a later link would then
|
||||
// not find it. Said, rather than failing the whole link over it.
|
||||
Status = $"{said} (The '{SpelunkTag}' tag could not be added: {exception.Message})";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The <see cref="SpelunkTag"/> tag in a vault, made there if it has none.</summary>
|
||||
private async Task<Guid> EnsureSpelunkTagAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||
{
|
||||
var listing = await session.Tags.ListAsync(vaultId, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (listing.Items
|
||||
.Where(tag => IsSpelunkTag(tag.Secret))
|
||||
.OrderBy(tag => tag.EntityId)
|
||||
.FirstOrDefault() is { } existing)
|
||||
{
|
||||
return existing.EntityId;
|
||||
}
|
||||
|
||||
return await session.Tags
|
||||
.CreateAsync(vaultId, new TagSecret { Label = SpelunkTag }, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether a host needs this key or credential to connect: as its resolved binding, or as the credential
|
||||
/// holding its Spelunk API key.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The one question the deletion warning, the table's USED BY column and the detail pane's list all ask,
|
||||
/// so a credential that is only somebody's Spelunk key is not deleted as though nothing used it. Spelunk
|
||||
/// keys are named by the host itself and never inherited, so the stored route is the whole answer there.
|
||||
/// A host Spelunk signs in to uses no binding at all, whatever its group lends.
|
||||
/// </remarks>
|
||||
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
||||
(!row.Host.SignsInThroughSpelunk
|
||||
&& row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == kind
|
||||
&& binding.EntityId == entityId)
|
||||
|| (kind is ResolvedBindingKind.Credential && row.Host.Spelunk?.ApiKeyCredentialId == entityId);
|
||||
|
||||
/// <summary>Puts a host's route into the editor, or clears it for a host that has none.</summary>
|
||||
private void LoadEditorSpelunk(SpelunkRoute? route)
|
||||
{
|
||||
EditorThroughSpelunk = route is not null;
|
||||
EditorSpelunkSignIn = route?.SignInThroughSpelunk ?? false;
|
||||
EditorSpelunkApi = route?.ApiBase ?? string.Empty;
|
||||
EditorSpelunkServerId = route?.ServerId.ToString() ?? string.Empty;
|
||||
|
||||
BuildSpelunkKeyChoices(route?.ApiKeyCredentialId);
|
||||
}
|
||||
|
||||
/// <summary>Fills the API key picker, keeping whatever the host names selectable.</summary>
|
||||
private void BuildSpelunkKeyChoices(Guid? selectedId)
|
||||
{
|
||||
EditorSpelunkKeyChoices.Clear();
|
||||
|
||||
foreach (var credential in Credentials)
|
||||
{
|
||||
EditorSpelunkKeyChoices.Add(AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
|
||||
}
|
||||
|
||||
if (selectedId is { } missing && EditorSpelunkKeyChoices.All(choice => choice.EntityId != missing))
|
||||
{
|
||||
EditorSpelunkKeyChoices.Add(AuthenticationChoice.Missing(AuthenticationKind.Credential, missing));
|
||||
}
|
||||
|
||||
EditorSelectedSpelunkKey = EditorSpelunkKeyChoices.FirstOrDefault(choice => choice.EntityId == selectedId);
|
||||
}
|
||||
|
||||
/// <summary>The editor's route, or null when the box is not ticked.</summary>
|
||||
/// <remarks>
|
||||
/// What cannot be read is written as empty rather than left out, so that
|
||||
/// <see cref="HostSecret.TryValidate"/> says what is wrong with it — "needs the id of its Spelunk server"
|
||||
/// — instead of the save quietly producing a host that is dialled directly. The address is normalised
|
||||
/// here when it can be, so <c>https://api.example.com/</c> saves as the same Spelunk as the link's.
|
||||
/// </remarks>
|
||||
private SpelunkRoute? BuildSpelunkRoute()
|
||||
{
|
||||
if (!EditorThroughSpelunk)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SpelunkRoute
|
||||
{
|
||||
ApiBase = SpelunkRoute.TryNormalizeApiBase(EditorSpelunkApi, out var normalized, out _)
|
||||
? normalized
|
||||
: EditorSpelunkApi.Trim(),
|
||||
ServerId = Guid.TryParse(EditorSpelunkServerId.Trim(), out var serverId) ? serverId : Guid.Empty,
|
||||
ApiKeyCredentialId = EditorSelectedSpelunkKey?.EntityId ?? Guid.Empty,
|
||||
SignInThroughSpelunk = EditorSpelunkSignIn,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1482,7 +1482,8 @@ internal sealed partial class VaultViewModel(
|
||||
Func<string, Task>? copyToClipboard = null,
|
||||
ConnectionRecorder? connectionLog = null,
|
||||
VaultVisibility? visibility = null,
|
||||
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
||||
ISshReachabilityProbe? reachability = null,
|
||||
ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||
/// <remarks>
|
||||
@@ -4307,10 +4308,15 @@ internal sealed partial class VaultViewModel(
|
||||
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
|
||||
/// every host under a group that binds a key, and the box would do nothing.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// False for a host Spelunk signs in to, whatever it resolves to: there is nothing to type, and a box —
|
||||
/// or on the phone, a sheet — asking for a password there would be a question with no use for its answer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null
|
||||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
||||
|| (!SelectedHost.Host.SignsInThroughSpelunk
|
||||
&& SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword);
|
||||
|
||||
/// <summary>
|
||||
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
||||
@@ -4329,11 +4335,13 @@ internal sealed partial class VaultViewModel(
|
||||
/// changed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
|
||||
internal string SelectedHostAuthenticationNote => SelectedHost switch
|
||||
{
|
||||
{ Kind: ResolvedBindingKind.Credential } binding =>
|
||||
{ Host.SignsInThroughSpelunk: true } =>
|
||||
"Spelunk signs you in to this host with a one-time key; no SSH key or password is needed.",
|
||||
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential } binding } =>
|
||||
$"This host uses a password stored in your keychain{From(binding)}.",
|
||||
{ Kind: ResolvedBindingKind.SshKey } binding =>
|
||||
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey } binding } =>
|
||||
$"This host authenticates with an SSH key{From(binding)}.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
@@ -4362,14 +4370,19 @@ internal sealed partial class VaultViewModel(
|
||||
/// <remarks>
|
||||
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
|
||||
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
|
||||
/// value that had not loaded.
|
||||
/// value that had not loaded. A host Spelunk signs in to logs in as whatever Spelunk names, so it says
|
||||
/// that rather than a username the connection will not use.
|
||||
/// </remarks>
|
||||
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
|
||||
? user
|
||||
: "no account set";
|
||||
internal string SelectedHostUsernameLabel => SelectedHost switch
|
||||
{
|
||||
{ Host.SignsInThroughSpelunk: true } => "named by Spelunk",
|
||||
{ Resolved.Username.Value: { Length: > 0 } user } => user,
|
||||
_ => "no account set",
|
||||
};
|
||||
|
||||
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
||||
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
|
||||
internal bool SelectedHostUsernameIsInherited =>
|
||||
SelectedHost is { Host.SignsInThroughSpelunk: false, Resolved.Username.IsInherited: true };
|
||||
|
||||
/// <summary>
|
||||
/// What the pane names in the credentials row: the key or password this host authenticates with.
|
||||
@@ -4385,11 +4398,12 @@ internal sealed partial class VaultViewModel(
|
||||
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
|
||||
internal string SelectedHostBindingLabel => SelectedHost switch
|
||||
{
|
||||
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
|
||||
{ Host.SignsInThroughSpelunk: true } => SpelunkSignInIdentity,
|
||||
{ Resolved.Binding: { Kind: ResolvedBindingKind.SshKey, EntityId: { } id } } =>
|
||||
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
|
||||
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
|
||||
{ Resolved.Binding: { Kind: ResolvedBindingKind.Credential, EntityId: { } id } } =>
|
||||
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
||||
?? "(a password that is no longer here)",
|
||||
_ => string.Empty,
|
||||
@@ -4588,6 +4602,7 @@ internal sealed partial class VaultViewModel(
|
||||
// longer say what the user chose.
|
||||
var authentication = EditorSelectedAuthentication;
|
||||
var groupId = EditorSelectedGroup?.EntityId;
|
||||
var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId;
|
||||
|
||||
BuildTagChoices();
|
||||
|
||||
@@ -4606,6 +4621,8 @@ internal sealed partial class VaultViewModel(
|
||||
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
|
||||
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
|
||||
grouped: EditorSelectedGroup?.EntityId is not null);
|
||||
|
||||
BuildSpelunkKeyChoices(spelunkKeyId);
|
||||
}
|
||||
|
||||
if (IsEditingGroup)
|
||||
@@ -7745,6 +7762,7 @@ internal sealed partial class VaultViewModel(
|
||||
EditorUsername = string.Empty;
|
||||
EditorNotes = string.Empty;
|
||||
EditorRelayEnabled = false;
|
||||
LoadEditorSpelunk(route: null);
|
||||
editorTagIds = TagSet.Empty;
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
@@ -7836,6 +7854,7 @@ internal sealed partial class VaultViewModel(
|
||||
EditorUsername = row.Host.Username ?? string.Empty;
|
||||
EditorNotes = row.Host.Notes ?? string.Empty;
|
||||
EditorRelayEnabled = row.Host.RelayEnabled;
|
||||
LoadEditorSpelunk(row.Host.Spelunk);
|
||||
editorTagIds = row.Host.TagIds;
|
||||
EditorNewTag = string.Empty;
|
||||
BuildTagChoices();
|
||||
@@ -11047,9 +11066,7 @@ internal sealed partial class VaultViewModel(
|
||||
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
|
||||
{
|
||||
var bound = Hosts
|
||||
.Where(row => row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == kind
|
||||
&& binding.EntityId == entityId)
|
||||
.Where(row => Uses(row, kind, entityId))
|
||||
.Select(row => row.Label)
|
||||
.ToArray();
|
||||
|
||||
@@ -11876,10 +11893,13 @@ internal sealed partial class VaultViewModel(
|
||||
PhaseReporter reporter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
|
||||
// probe does not take: it would dial the host directly — the route the user chose the relay because
|
||||
// they cannot use — and report on that instead.
|
||||
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
|
||||
// A relay or Spelunk refusal already says exactly what happened, and a routed host is reached by a
|
||||
// path the probe does not take: it would dial the host directly — the route the user chose the relay
|
||||
// or Spelunk because they cannot use, or for a Spelunk host an address that exists only to name its
|
||||
// key — and report on that instead.
|
||||
if (reporter.Furthest != SshConnectionPhase.Reaching
|
||||
|| exception is ServerRelayException or SpelunkTunnelException
|
||||
|| target.Routed)
|
||||
{
|
||||
return exception.Message;
|
||||
}
|
||||
@@ -11984,7 +12004,11 @@ internal sealed partial class VaultViewModel(
|
||||
authentication.Username,
|
||||
authentication.Credential);
|
||||
|
||||
var sessionId = await OpenThroughAsync(target, request, reporter, cancellationToken).ConfigureAwait(true);
|
||||
var (sessionId, dialled) = await OpenThroughAsync(target, request, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
// The account the session actually logged in as, which a Spelunk sign-in learns only from the tunnel.
|
||||
authentication = authentication with { Username = dialled.Username };
|
||||
|
||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
||||
@@ -12046,11 +12070,11 @@ internal sealed partial class VaultViewModel(
|
||||
/// this machine without touching the host.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
|
||||
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
|
||||
/// Reached through <see cref="OpenRouteAsync"/>, which is what both transports call — file transfer
|
||||
/// opens a connection of its own, and a relayed host needs the relay there too.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
||||
private async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
@@ -12063,22 +12087,23 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
|
||||
/// Opens the session, through the relay or Spelunk when the host uses one, and puts the route's reason on
|
||||
/// a failure.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
||||
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
|
||||
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
|
||||
/// host key refusals are left alone: a key was presented, so the relay did its job.
|
||||
/// A route that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
||||
/// every lost transport — a sentence that reads exactly like the direct failure the route was chosen to
|
||||
/// get around. When the route said why it closed, that sentence is the true one and replaces it. The
|
||||
/// host key refusals are left alone: a key was presented, so the route did its job.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
|
||||
/// The route is disposed on every failure. The connection owns it once it is handed over and disposes
|
||||
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
||||
/// bridge listening until its own timeout; disposing twice is safe.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<uint> OpenThroughAsync(
|
||||
private async Task<(uint SessionId, SshConnectionRequest Request)> OpenThroughAsync(
|
||||
ConnectionTarget target,
|
||||
SshConnectionRequest request,
|
||||
PhaseReporter reporter,
|
||||
@@ -12086,28 +12111,30 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
||||
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
||||
var relay = target.Relayed
|
||||
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
|
||||
var route = target.Row is { } row
|
||||
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
|
||||
: null;
|
||||
|
||||
var through = Through(request, route);
|
||||
|
||||
try
|
||||
{
|
||||
return await workspace
|
||||
.OpenSessionAsync(
|
||||
relay is null ? request : request with { Proxy = relay.Proxy },
|
||||
TerminalSize.Default,
|
||||
reporter,
|
||||
cancellationToken)
|
||||
var sessionId = await workspace
|
||||
.OpenSessionAsync(through, TerminalSize.Default, reporter, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (relay is not null)
|
||||
{
|
||||
await relay.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
if (relay.Failure is { } failure
|
||||
return (sessionId, through);
|
||||
}
|
||||
catch (Exception exception) when (route is not null)
|
||||
{
|
||||
await route.DisposeAsync().ConfigureAwait(true);
|
||||
|
||||
if (route.Failure is { } failure
|
||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||
{
|
||||
throw new ServerRelayException(failure, exception);
|
||||
throw route is SpelunkTunnelRoute
|
||||
? new SpelunkTunnelException(failure, exception)
|
||||
: new ServerRelayException(failure, exception);
|
||||
}
|
||||
|
||||
throw;
|
||||
@@ -12334,13 +12361,13 @@ internal sealed partial class VaultViewModel(
|
||||
internal Guid? HostId => Row?.EntityId;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is reached through the server relay rather than dialled.
|
||||
/// Whether this is reached through the server relay or through Spelunk rather than dialled.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
|
||||
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
internal bool Relayed => Row?.Host.RelayEnabled is true;
|
||||
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -12422,8 +12449,25 @@ internal sealed partial class VaultViewModel(
|
||||
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
|
||||
/// nothing about keys.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>A host Spelunk signs in to is answered first, whatever it or its group binds</b> — see
|
||||
/// <see cref="SignedInBySpelunk"/>. It is the one host that connects with no binding, because it says so;
|
||||
/// every other host goes through <see cref="TryBuildBoundAuthentication"/> exactly as before.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private bool TryBuildAuthentication(
|
||||
HostSecret host,
|
||||
ResolvedHost resolved,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||
[NotNullWhen(false)] out string? reason) =>
|
||||
host.SignsInThroughSpelunk
|
||||
? SignedInBySpelunk(out authentication, out reason)
|
||||
: TryBuildBoundAuthentication(host, resolved, typedPassword, out authentication, out reason);
|
||||
|
||||
/// <summary>Turns a host's resolved binding into a key, a password or a prompt, or says why it cannot.</summary>
|
||||
/// <remarks>See <see cref="TryBuildAuthentication"/>.</remarks>
|
||||
private bool TryBuildBoundAuthentication(
|
||||
HostSecret host,
|
||||
ResolvedHost resolved,
|
||||
string typedPassword,
|
||||
@@ -12488,6 +12532,27 @@ internal sealed partial class VaultViewModel(
|
||||
out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How a host Spelunk signs in to authenticates, before the tunnel that decides it is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Spelunk signs the host in with a key the tunnel makes and an account Spelunk names, neither of which
|
||||
/// exists yet. These stand-ins are replaced as the connection is made through the route — see
|
||||
/// <see cref="Through"/> — and refused by the SSH layer if they ever reach it. It is the only way to
|
||||
/// connect without a binding, and only for a host that says so; every other host still needs one.
|
||||
/// </remarks>
|
||||
private static bool SignedInBySpelunk(
|
||||
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||
out string? reason)
|
||||
{
|
||||
authentication = new HostAuthentication(
|
||||
SpelunkSignIn.DefaultUser,
|
||||
SpelunkSignInPending.Instance,
|
||||
SpelunkSignInIdentity);
|
||||
reason = null;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last thing every branch has to agree on: there is somebody to log in as.
|
||||
/// </summary>
|
||||
@@ -12549,6 +12614,10 @@ internal sealed partial class VaultViewModel(
|
||||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||||
RelayEnabled = EditorRelayEnabled,
|
||||
|
||||
// Null unless the box is ticked, whatever the fields below it still hold: unticking is how a host
|
||||
// goes back to being dialled, and it must not keep a route the user just turned off.
|
||||
Spelunk = BuildSpelunkRoute(),
|
||||
|
||||
// Only ever true or null, never false: the two would mean the same thing, and writing false would
|
||||
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
|
||||
//
|
||||
@@ -12556,7 +12625,9 @@ internal sealed partial class VaultViewModel(
|
||||
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
|
||||
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
|
||||
// host in the vault the first time it was edited.
|
||||
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
||||
// Never beside a Spelunk sign-in, nor are the bindings below: a host has one way to log in.
|
||||
AsksForPassword = EditorAsksForLogin
|
||||
&& EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
||||
&& EditorSelectedGroup?.EntityId is not null
|
||||
? true
|
||||
: null,
|
||||
@@ -12591,7 +12662,9 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
||||
private Guid? Bound(AuthenticationKind kind) =>
|
||||
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
||||
EditorAsksForLogin && EditorSelectedAuthentication is { } choice && choice.Kind == kind
|
||||
? choice.EntityId
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
||||
@@ -13322,8 +13395,7 @@ internal sealed partial class VaultViewModel(
|
||||
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
||||
{
|
||||
var names = Hosts
|
||||
.Where(row => row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == kind && binding.EntityId == entityId)
|
||||
.Where(row => Uses(row, kind, entityId))
|
||||
.Select(row => row.Label)
|
||||
.ToList();
|
||||
|
||||
@@ -13518,8 +13590,7 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
if (kind is { } resolvedKind && value is not null)
|
||||
{
|
||||
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
|
||||
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
|
||||
foreach (var row in Hosts.Where(row => Uses(row, resolvedKind, value.EntityId)))
|
||||
{
|
||||
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
||||
}
|
||||
|
||||
@@ -268,12 +268,14 @@ internal sealed class HostKind : IItemKind<HostSecret>
|
||||
Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
|
||||
Note(changed, "Options", before.Options, after.Options);
|
||||
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
|
||||
Note(changed, "Spelunk", before.Spelunk, after.Spelunk);
|
||||
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
|
||||
Note(changed, "Credential", before.CredentialId, after.CredentialId);
|
||||
Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
|
||||
Note(changed, "Group", before.GroupId, after.GroupId);
|
||||
Note(changed, "Tags", before.TagIds, after.TagIds);
|
||||
Note(changed, "Port forwards", before.PortForwards, after.PortForwards);
|
||||
Note(changed, "Pinned paths", before.PinnedPaths, after.PinnedPaths);
|
||||
|
||||
return changed;
|
||||
}
|
||||
|
||||
@@ -1020,10 +1020,10 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has fourteen of them —
|
||||
/// eleven until inheritance added "Password prompt", tags added "Tags" and forwards added "Port
|
||||
/// forwards". Measured with an edit that touched several, because one field name fits anywhere. The
|
||||
/// count is stated rather than derived, so
|
||||
/// The FIELDS column is the one that grows: it is a list of names, and a host has fifteen of them —
|
||||
/// eleven until inheritance added "Password prompt", tags added "Tags", forwards added "Port forwards"
|
||||
/// and pinning added "Pinned paths". Measured with an edit that touched several, because one field
|
||||
/// name fits anywhere. The count is stated rather than derived, so
|
||||
/// it has to be recounted against <c>HostKind.Changes</c> whenever a field is added; the literal in
|
||||
/// <c>SeedLogsAsync</c> is the thing that actually keeps the column measured at its worst.
|
||||
/// </remarks>
|
||||
@@ -2252,7 +2252,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
ItemId = Guid.CreateVersion7(),
|
||||
ItemLabel = "customer-a-production-database",
|
||||
Operation = ActivityOperation.Updated,
|
||||
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags, Port forwards",
|
||||
ChangedFields = "Hostname, Port, Username, Options, Password prompt, Group, Tags, Port forwards, Pinned paths",
|
||||
At = new DateTimeOffset(2026, 7, 30, 9, 15, 0, TimeSpan.Zero),
|
||||
DeviceName = "jaap-jan-workstation",
|
||||
},
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A host reached through Spelunk, from the editor and from a link to the request that reaches SSH.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The tunnel is stood in for at <see cref="ISpelunkTunnel"/>, and what it hands back is a real
|
||||
/// <see cref="SpelunkTunnelRoute"/> over the relay's real bridge and an in-process WebSocket pair — so the
|
||||
/// proxy SSH is given, and the close code a failed connect is explained by, are the real ones. The tunnel's
|
||||
/// own HTTP and WebSocket half is <c>SpelunkTunnelTests</c>' job.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// What these hold is what the tick box and the link promise: a Spelunk host is never dialled directly and
|
||||
/// never probed, a refusal is said in Spelunk's terms, the key goes only where the host says, a sign-in
|
||||
/// connects with the tunnel's key and Spelunk's account and nothing of the host's, and a link connects
|
||||
/// straight away but never sends a key to a Spelunk no saved host already sends it to.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed partial class ShellFlowTests
|
||||
{
|
||||
private const string SpelunkApi = "https://api.spelunk.example.com";
|
||||
|
||||
private const string SpelunkApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||
|
||||
private static readonly Guid SpelunkServer = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||
|
||||
private readonly FakeSpelunkTunnel spelunkTunnels = new();
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHost_IsDialledThroughTheTunnel_AndStillAsItself()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// The key from the credential, to the Spelunk and server the host names, for the host's own port.
|
||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
opened.ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
||||
|
||||
// Through the loopback proxy, and named as the host, because the name is what its key is pinned under.
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
request.Host.ShouldBe("203.0.113.10");
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ARefusedTunnel_IsExplained_AndNothingIsDialledOrProbed()
|
||||
{
|
||||
spelunkTunnels.Refusal = new SpelunkTunnelException(
|
||||
"The Spelunk agent on this server is not connected, so Spelunk cannot reach it.");
|
||||
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
// No fallback to the address. For a Spelunk host it may be nothing that answers at all.
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
reachability.Probed.ShouldBeEmpty();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("agent on this server is not connected");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATunnelWhoseAgentCouldNotReachThePort_SaysSo_InsteadOfTheTransportError()
|
||||
{
|
||||
// What Spelunk does when the agent's own connect to 127.0.0.1:22 fails: accept, then close with 4502.
|
||||
spelunkTunnels.CloseWith = (4502, "dial tcp 127.0.0.1:22: connection refused");
|
||||
|
||||
ssh.BeforeConnect = request => ThroughSocksToTheEndAsync(request.Proxy.ShouldNotBeNull().Port);
|
||||
ssh.Failure = new InvalidOperationException("An established connection was aborted by the server.");
|
||||
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("could not connect to port 22 on the server itself");
|
||||
vault.Status.ShouldNotContain("aborted");
|
||||
reachability.Probed.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHostsFileTransfer_GoesThroughTheTunnelToo()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
var request = ssh.SftpRequests.ShouldHaveSingleItem();
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheRelayAndSpelunk_AreOneChoice_InTheEditor()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
|
||||
vault.EditorRelayEnabled = true;
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorRelayEnabled.ShouldBeFalse("ticking Spelunk unticks the relay");
|
||||
|
||||
vault.EditorRelayEnabled = true;
|
||||
vault.EditorThroughSpelunk.ShouldBeFalse("and the other way round");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHostWithNoKeyChosen_IsNotSaved()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi;
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("Spelunk API key");
|
||||
vault.Hosts.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASavedSpelunkHost_OpensInTheEditorWithItsRoute()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
|
||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
||||
}
|
||||
|
||||
[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 ----
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInHost_ConnectsWithAFreshKeyAndSpelunksAccount_AndNoLoginOfItsOwn()
|
||||
{
|
||||
spelunkTunnels.User = "ops";
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeFalse("there is nothing to type");
|
||||
vault.SelectedHostAuthenticationNote.ShouldContain("Spelunk signs you in");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem().ShouldBe((SpelunkApi, SpelunkServer, SpelunkApiKey, 22));
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 ");
|
||||
|
||||
// The account Spelunk named and the key the tunnel was opened with, through the tunnel, as the host.
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Username.ShouldBe("ops");
|
||||
request.Credential.ShouldBeSameAs(signIn.Credential);
|
||||
request.Host.ShouldBe("203.0.113.10");
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
shell.Tabs.ShouldHaveSingleItem();
|
||||
vault.Credentials.Count.ShouldBe(1, "nothing is remembered for a sign-in");
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInHostsForwards_StillStart_OverTheSignedInSession()
|
||||
{
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewForwardKind = (int)Domain.PortForwardKind.Dynamic;
|
||||
vault.EditorNewForwardBindPort = "1080";
|
||||
vault.EditorNewForwardTarget = string.Empty;
|
||||
vault.AddEditorForwardCommand.Execute(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||
await EventuallyAsync(() => tab.ListeningForwardCount == 1, "the forward should be listening");
|
||||
ssh.Forwards.ShouldHaveSingleItem().Spec.ShouldBe(new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080));
|
||||
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInHostsFileTransfer_SignsInThroughSpelunkToo()
|
||||
{
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
shell.Transfers.Attach(vault, knownHosts);
|
||||
shell.Transfers.SelectedHost = shell.Transfers.Hosts[0];
|
||||
shell.Transfers.SelectedHostAsksForAPassword.ShouldBeFalse();
|
||||
|
||||
await shell.Transfers.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
var request = ssh.SftpRequests.ShouldHaveSingleItem();
|
||||
request.Username.ShouldBe("root");
|
||||
request.Credential.ShouldBeSameAs(signIn.Credential);
|
||||
request.Proxy.ShouldNotBeNull().Lifetime.ShouldBeOfType<RelayBridge>();
|
||||
shell.Transfers.ConnectedTo.ShouldStartWith("root@203.0.113.10");
|
||||
|
||||
await request.Proxy.Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpelunkHostThatDoesNotSignIn_StillNeedsALogin()
|
||||
{
|
||||
// The sign-in is what lets a host connect with no binding; without it, the old rule stands.
|
||||
var vault = await ReadyToSignInThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSpelunkSignIn = false;
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single();
|
||||
vault.SelectedHostAsksForAPassword.ShouldBeTrue();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.ConnectCommand.ExecuteAsync(null);
|
||||
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("no username");
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TickingSignIn_GreysOutTheLogin_AndTheSavedHostBindsNothing()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorAsksForLogin.ShouldBeTrue();
|
||||
|
||||
vault.EditorSpelunkSignIn = true;
|
||||
vault.EditorAsksForLogin.ShouldBeFalse();
|
||||
|
||||
// The choice is still there to see, and to come back to, but it is not saved beside the sign-in.
|
||||
vault.EditorSelectedAuthentication.ShouldNotBeNull().Label.ShouldBe("deploy password");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
var host = vault.Hosts.ShouldHaveSingleItem().Host;
|
||||
host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
host.CredentialId.ShouldBeNull();
|
||||
host.SshKeyId.ShouldBeNull();
|
||||
host.AsksForPassword.ShouldBeNull();
|
||||
|
||||
// Unticking the route altogether gives the login back.
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorSpelunkSignIn.ShouldBeTrue();
|
||||
vault.EditorThroughSpelunk = false;
|
||||
vault.EditorAsksForLogin.ShouldBeTrue();
|
||||
}
|
||||
|
||||
// ---- A link from Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForAServerWithNoHost_OpensTheEditor_AndConnectsNothing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
shell.OpenLink(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorLabel.ShouldBe("web 01");
|
||||
vault.EditorHostname.ShouldBe("203.0.113.10");
|
||||
vault.EditorPort.ShouldBe(22);
|
||||
vault.EditorThroughSpelunk.ShouldBeTrue();
|
||||
vault.EditorSpelunkSignIn.ShouldBeTrue("a host from a link signs in through Spelunk");
|
||||
vault.EditorAsksForLogin.ShouldBeFalse();
|
||||
vault.EditorSpelunkApi.ShouldBe(SpelunkApi);
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
|
||||
// No key chosen for a Spelunk no host already uses: the link named the address, and guessing a key
|
||||
// for it would be offering to send the key wherever a link says.
|
||||
vault.EditorSelectedSpelunkKey.ShouldBeNull();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("first server");
|
||||
|
||||
spelunkTunnels.Opened.ShouldBeEmpty();
|
||||
ssh.Requests.ShouldBeEmpty();
|
||||
vault.Hosts.ShouldBeEmpty("nothing is saved until the user saves it");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkWithNoAddress_NamesTheHostAfterTheServer()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
shell.OpenLink(Link(host: null));
|
||||
|
||||
shell.Vault!.EditorHostname.ShouldBe($"spelunk-{SpelunkServer}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheFirstLinkFromASpelunk_TagsTheHostItOpens_InThePersonalVault()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSelectedVault.ShouldNotBeNull().IsPersonal.ShouldBeTrue();
|
||||
vault.EditorTagChoices.ShouldHaveSingleItem().ShouldSatisfyAllConditions(
|
||||
chip => chip.Label.ShouldBe(VaultViewModel.SpelunkTag),
|
||||
chip => chip.IsWorn.ShouldBeTrue());
|
||||
|
||||
// So once it is saved, the next link for that server finds it rather than adding a second one.
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.Hosts.ShouldHaveSingleItem();
|
||||
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForAnotherServerOnAKnownSpelunk_IsAddedAndConnected_WithoutTheEditor()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: null, server: other));
|
||||
|
||||
vault.IsEditing.ShouldBeFalse("nothing to choose: the key already goes to this Spelunk");
|
||||
|
||||
// Saved as a host like any other: in the personal vault, tagged, signing in through Spelunk with the
|
||||
// key that Spelunk already gets, and no login of its own.
|
||||
var added = vault.Hosts.Single(row => row.Host.Spelunk?.ServerId == other);
|
||||
added.VaultId.ShouldBe(vault.TargetVaults.Single(choice => choice.IsPersonal).VaultId);
|
||||
added.Label.ShouldBe("web 01");
|
||||
added.Host.Hostname.ShouldBe($"spelunk-{other}");
|
||||
added.Host.Port.ShouldBe(22);
|
||||
added.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
|
||||
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.CredentialId.ShouldBeNull();
|
||||
added.Host.Username.ShouldBeNull();
|
||||
added.Host.TagIds.ShouldHaveSingleItem().ShouldBe(vault.Tags.Single(tag => tag.Label == VaultViewModel.SpelunkTag).EntityId);
|
||||
|
||||
// And connected, through Spelunk, signing in.
|
||||
var opened = spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
opened.ShouldBe((SpelunkApi, other, SpelunkApiKey, 22));
|
||||
var signIn = spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldNotBeNull();
|
||||
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Username.ShouldBe("root");
|
||||
request.Credential.ShouldBeSameAs(signIn.Credential);
|
||||
shell.IsTerminalShowing.ShouldBeTrue();
|
||||
|
||||
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASecondLinkForTheSameServer_ConnectsToTheHostTheFirstAdded()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
var other = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(server: other));
|
||||
await vault.OpenSpelunkLinkAsync(Link(server: other));
|
||||
|
||||
vault.Hosts.Count(row => row.Host.Spelunk?.ServerId == other).ShouldBe(1);
|
||||
spelunkTunnels.Opened.Count.ShouldBe(2);
|
||||
|
||||
// The two SignIns are two keys: one per connection, never reused.
|
||||
spelunkTunnels.SignIns[0].ShouldNotBeNull().PublicKeyLine
|
||||
.ShouldNotBe(spelunkTunnels.SignIns[1].ShouldNotBeNull().PublicKeyLine);
|
||||
|
||||
foreach (var request in ssh.Requests)
|
||||
{
|
||||
await request.Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForATaggedHostInThePersonalVault_ConnectsToItAsSaved()
|
||||
{
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
vault.EditSelectedHostCommand.Execute(null);
|
||||
vault.EditorNewTag = VaultViewModel.SpelunkTag;
|
||||
await vault.AddEditorTagCommand.ExecuteAsync(null);
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
|
||||
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
vault.Hosts.ShouldHaveSingleItem();
|
||||
spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||
|
||||
// The saved host, as saved — not the address the link happened to carry, and its own login, since
|
||||
// this one does not sign in through Spelunk.
|
||||
spelunkTunnels.SignIns.ShouldHaveSingleItem().ShouldBeNull();
|
||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||
request.Host.ShouldBe("203.0.113.10");
|
||||
request.Username.ShouldBe("deploy");
|
||||
|
||||
await request.Proxy.ShouldNotBeNull().Lifetime.DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkForAHostSetUpByHand_AddsOneOfItsOwn()
|
||||
{
|
||||
// Untagged, so not one a link made: it is left as it is, and the link gets the host links make.
|
||||
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||
|
||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||
await vault.OpenSpelunkLinkAsync(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.Hosts.Count.ShouldBe(2);
|
||||
vault.Hosts.Single(row => row.Label == "web-01").Host.TagIds.ShouldBeEmpty();
|
||||
vault.Hosts.Single(row => row.Label == "web 01").Host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
|
||||
await ssh.Requests.ShouldHaveSingleItem().Proxy.ShouldNotBeNull().Lifetime.ShouldNotBeNull().DisposeAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALinkWhileLocked_WaitsForTheUnlock()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
shell.OpenLink(Link(host: "203.0.113.10"));
|
||||
|
||||
shell.Vault.ShouldBeNull();
|
||||
shell.StatusMessage.ShouldContain("Unlock to open web 01");
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
vault.IsEditing.ShouldBeTrue();
|
||||
vault.EditorSpelunkServerId.ShouldBe(SpelunkServer.ToString());
|
||||
vault.EditorSpelunkSignIn.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALink_LeavesAHalfEditedHostAlone()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "something I was typing";
|
||||
|
||||
shell.OpenLink(Link(host: "203.0.113.10"));
|
||||
|
||||
vault.EditorLabel.ShouldBe("something I was typing");
|
||||
vault.EditorThroughSpelunk.ShouldBeFalse();
|
||||
vault.Status.ShouldNotBeNull().ShouldContain("Finish or cancel");
|
||||
}
|
||||
|
||||
/// <summary>A saved host through Spelunk, bound to a stored SSH password, with its key in the keychain.</summary>
|
||||
private async Task<VaultViewModel> ReadyToConnectThroughSpelunkAsync()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
await AddCredentialAsync(vault, "deploy password", password: "s3cret", username: "deploy");
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
vault.EditorUsername = "deploy";
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi + "/";
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
vault.EditorSelectedAuthentication = vault.EditorAuthenticationChoices
|
||||
.Single(choice => choice.Label == "deploy password");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.Single(row => row.Host.Spelunk is not null);
|
||||
|
||||
// Saved in the one form, whatever was typed — which is what lets a link find it.
|
||||
vault.SelectedHost.Host.Spelunk!.ApiBase.ShouldBe(SpelunkApi);
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
/// <summary>A saved host through Spelunk that Spelunk signs in to: no username, no key, no password.</summary>
|
||||
private async Task<VaultViewModel> ReadyToSignInThroughSpelunkAsync()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddCredentialAsync(vault, "spelunk key", password: SpelunkApiKey);
|
||||
|
||||
vault.NewHostCommand.Execute(null);
|
||||
vault.EditorLabel = "web-01";
|
||||
vault.EditorHostname = "203.0.113.10";
|
||||
vault.EditorThroughSpelunk = true;
|
||||
vault.EditorSpelunkSignIn = true;
|
||||
vault.EditorSpelunkApi = SpelunkApi;
|
||||
vault.EditorSpelunkServerId = SpelunkServer.ToString();
|
||||
vault.EditorSelectedSpelunkKey = vault.EditorSpelunkKeyChoices.Single(choice => choice.Label == "spelunk key");
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
|
||||
vault.SelectedHost = vault.Hosts.ShouldHaveSingleItem(vault.Status);
|
||||
vault.SelectedHost.Host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
|
||||
return vault;
|
||||
}
|
||||
|
||||
private static SpelunkLink Link(string? host = "203.0.113.10", Guid? server = null) =>
|
||||
new(SpelunkApi, server ?? SpelunkServer, "web 01", host);
|
||||
|
||||
/// <summary>
|
||||
/// Speaks SOCKS5 to the bridge as SSH.NET would, then reads until the bridge closes the stream — which it
|
||||
/// does once the tunnel has closed, so its reason is recorded by the time this returns.
|
||||
/// </summary>
|
||||
private static async Task ThroughSocksToTheEndAsync(int port)
|
||||
{
|
||||
using var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port, Token);
|
||||
|
||||
var stream = client.GetStream();
|
||||
var name = Encoding.ASCII.GetBytes("203.0.113.10");
|
||||
|
||||
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
|
||||
await stream.ReadExactlyAsync(new byte[2], Token);
|
||||
|
||||
await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token);
|
||||
await stream.ReadExactlyAsync(new byte[10], Token);
|
||||
|
||||
var rest = new byte[256];
|
||||
while (await stream.ReadAsync(rest, Token) > 0)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk as far as the shell can see it: a route over a real bridge, or a refusal, as a test arranges.
|
||||
/// </summary>
|
||||
private sealed class FakeSpelunkTunnel : ISpelunkTunnel
|
||||
{
|
||||
internal List<(string ApiBase, Guid ServerId, string ApiKey, int Port)> Opened { get; } = [];
|
||||
|
||||
/// <summary>The sign-in key each tunnel was asked for, in order; null for a plain tunnel.</summary>
|
||||
internal List<SpelunkSignIn?> SignIns { get; } = [];
|
||||
|
||||
/// <summary>The account a sign-in answer names.</summary>
|
||||
internal string User { get; set; } = "root";
|
||||
|
||||
internal SpelunkTunnelException? Refusal { get; set; }
|
||||
|
||||
/// <summary>A close Spelunk sends as soon as the tunnel is open, or null to leave it open.</summary>
|
||||
internal (int Code, string Reason)? CloseWith { get; set; }
|
||||
|
||||
public async Task<SpelunkTunnelRoute> OpenAsync(
|
||||
string apiBase,
|
||||
Guid serverId,
|
||||
string apiKey,
|
||||
int port,
|
||||
SpelunkSignIn? signIn,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Opened.Add((apiBase, serverId, apiKey, port));
|
||||
SignIns.Add(signIn);
|
||||
|
||||
if (Refusal is { } refusal)
|
||||
{
|
||||
throw refusal;
|
||||
}
|
||||
|
||||
var (client, far) = await PairAsync(cancellationToken);
|
||||
|
||||
if (CloseWith is { } close)
|
||||
{
|
||||
await far.CloseOutputAsync((WebSocketCloseStatus)close.Code, close.Reason, cancellationToken);
|
||||
}
|
||||
|
||||
return new SpelunkTunnelRoute(RelayBridge.Start(client), port, signIn, signIn is null ? null : User);
|
||||
}
|
||||
|
||||
private static async Task<(WebSocket Client, WebSocket Far)> PairAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
|
||||
var dialling = new TcpClient();
|
||||
var connecting = dialling.ConnectAsync((IPEndPoint)listener.LocalEndpoint, cancellationToken);
|
||||
var accepted = await listener.AcceptTcpClientAsync(cancellationToken);
|
||||
await connecting;
|
||||
|
||||
return (
|
||||
WebSocket.CreateFromStream(
|
||||
dialling.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = false, KeepAliveInterval = TimeSpan.Zero }),
|
||||
WebSocket.CreateFromStream(
|
||||
accepted.GetStream(),
|
||||
new WebSocketCreationOptions { IsServer = true, KeepAliveInterval = TimeSpan.Zero }));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -226,7 +226,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
// Scripted rather than real, so that a failed connection in any test in this file does not go
|
||||
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
|
||||
// the wall clock.
|
||||
reachability: reachability);
|
||||
reachability: reachability,
|
||||
|
||||
// Scripted for the same reason: a Spelunk host's connect never leaves the process. See
|
||||
// ShellFlowTests.Spelunk.cs.
|
||||
spelunk: spelunkTunnels);
|
||||
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
using System.IO.Pipes;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
@@ -198,4 +201,149 @@ public sealed class SingleInstanceTests : IDisposable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- A launch that carries a link ----
|
||||
|
||||
/// <remarks>
|
||||
/// Clicking "Open in DodoSSH" in Spelunk while DodoSSH is running is a second launch like any other, and
|
||||
/// the link has to reach the instance that can act on it — along with the window coming forward.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ARefusedLaunchWithALink_PassesTheLinkOn()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var cameForward = new TaskCompletionSource();
|
||||
var opened = new TaskCompletionSource<SpelunkLink>();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(
|
||||
paths,
|
||||
() => cameForward.TrySetResult(),
|
||||
link => opened.TrySetResult(link));
|
||||
|
||||
var link = Link();
|
||||
|
||||
SingleInstance.Claim(paths, Impatient, link).ShouldBeNull("the profile is taken");
|
||||
|
||||
await cameForward.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
(await opened.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken)).ShouldBe(link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ThePlainRing_IsStillThePlainRing()
|
||||
{
|
||||
// An older running build compares the whole line against this, so a launch with no link must go on
|
||||
// saying exactly it.
|
||||
SingleInstance.Message(link: null).ShouldBe("dodossh.come-forward");
|
||||
|
||||
SingleInstance.TryRead("dodossh.come-forward", out var link).ShouldBeTrue();
|
||||
link.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALinkMessage_ReadsBackToTheSameLink()
|
||||
{
|
||||
var link = Link();
|
||||
|
||||
SingleInstance.TryRead(SingleInstance.Message(link), out var read).ShouldBeTrue();
|
||||
|
||||
read.ShouldBe(link);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Anything on this machine can write to the pipe, so a message that is not exactly one of the two is
|
||||
/// ignored whole — the window does not even come forward for it.
|
||||
/// </remarks>
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("dodossh.come-forward ")]
|
||||
[InlineData("DODOSSH.COME-FORWARD")]
|
||||
[InlineData("dodossh.open-link")]
|
||||
[InlineData("dodossh.open-link https://evil.example")]
|
||||
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=http%3A%2F%2Fevil.example&server=0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b")]
|
||||
[InlineData("dodossh.open-link dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=not-a-server")]
|
||||
[InlineData("dodossh.run calc.exe")]
|
||||
public void AnythingElse_IsNotARing(string? said)
|
||||
{
|
||||
SingleInstance.TryRead(said, out var link).ShouldBeFalse();
|
||||
|
||||
link.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOverlongMessage_IsNotARing()
|
||||
{
|
||||
SingleInstance.TryRead("dodossh.open-link " + new string('a', 64 * 1024), out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AGarbledRing_IsIgnored_AndTheDoorbellKeepsAnswering()
|
||||
{
|
||||
using var first = SingleInstance.Claim(paths, Impatient);
|
||||
first.ShouldNotBeNull();
|
||||
|
||||
var rings = 0;
|
||||
var answered = new TaskCompletionSource();
|
||||
|
||||
await using var doorbell = SingleInstance.Listen(
|
||||
paths,
|
||||
() =>
|
||||
{
|
||||
Interlocked.Increment(ref rings);
|
||||
answered.TrySetResult();
|
||||
});
|
||||
|
||||
// Somebody else's program, writing something that is not a ring and going on far past a line. The
|
||||
// doorbell stops reading and hangs up part way, which the writer sees as a broken pipe.
|
||||
using (var pipe = new NamedPipeClientStream(".", PipeNameOf(paths), PipeDirection.Out))
|
||||
{
|
||||
await pipe.ConnectAsync(TestContext.Current.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await pipe.WriteAsync(
|
||||
Encoding.UTF8.GetBytes(new string('x', 20_000) + "\n"),
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Hung up on, which is the point.
|
||||
}
|
||||
}
|
||||
|
||||
SingleInstance.AskTheRunningOneToComeForward(paths).ShouldBeTrue();
|
||||
|
||||
await answered.Task.WaitAsync(LongEnoughForAPipe, TestContext.Current.CancellationToken);
|
||||
rings.ShouldBe(1, "the garbage rang nothing, and the real ring after it was still answered");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWindowsHandler_RunsThisExecutableWithTheLinkAsOneArgument()
|
||||
{
|
||||
Assert.SkipUnless(OperatingSystem.IsWindows(), "The registry handler exists only on Windows.");
|
||||
|
||||
const string executable = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe";
|
||||
const string nightly = @"C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop.Nightly\current\DodoSSH.exe";
|
||||
|
||||
WindowsUrlScheme.CommandFor(executable)
|
||||
.ShouldBe(@"""C:\Users\Jane Doe\AppData\Local\DodoSSH.Desktop\current\DodoSSH.exe"" ""%1""");
|
||||
|
||||
// Uninstalling one channel must not take the handler away from the other if it registered since.
|
||||
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(executable), executable).ShouldBeTrue();
|
||||
WindowsUrlScheme.IsOurs(WindowsUrlScheme.CommandFor(nightly), executable).ShouldBeFalse();
|
||||
WindowsUrlScheme.IsOurs(null, executable).ShouldBeFalse();
|
||||
}
|
||||
|
||||
private static SpelunkLink Link() =>
|
||||
new("https://api.spelunk.example.com", Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"), "web 01", "203.0.113.10");
|
||||
|
||||
/// <summary>The pipe name <c>SingleInstance</c> derives, so a test can write to it as a stranger would.</summary>
|
||||
private static string PipeNameOf(ClientPaths profile)
|
||||
{
|
||||
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(profile.DataDirectory));
|
||||
|
||||
return "dodossh-" + Convert.ToHexStringLower(digest.AsSpan(0, 8));
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,19 @@ internal static class HostFactory
|
||||
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>
|
||||
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||
|
||||
/// <summary>
|
||||
/// A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/> — signing in through Spelunk
|
||||
/// when <paramref name="signIn"/> says so.
|
||||
/// </summary>
|
||||
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com", bool signIn = false) =>
|
||||
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey, SignInThroughSpelunk = signIn };
|
||||
|
||||
/// <remarks>
|
||||
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
||||
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
|
||||
@@ -59,7 +72,8 @@ internal static class HostFactory
|
||||
Guid? groupId = null,
|
||||
Guid[]? tags = null,
|
||||
string[]? pinnedPaths = null,
|
||||
PortForwardRule[]? portForwards = null) =>
|
||||
PortForwardRule[]? portForwards = null,
|
||||
SpelunkRoute? spelunk = null) =>
|
||||
new()
|
||||
{
|
||||
Label = label,
|
||||
@@ -79,6 +93,7 @@ internal static class HostFactory
|
||||
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
|
||||
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
|
||||
PortForwards = portForwards is null ? PortForwardList.Empty : PortForwardList.Create(portForwards),
|
||||
Spelunk = spelunk,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -18,8 +18,11 @@ public sealed class HostSecretCodecTests
|
||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
||||
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
|
||||
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags, a
|
||||
/// pair of pinned paths and a pair of forwards — which are orthogonal to the binding and are what make
|
||||
/// this host reach the highest schema version a valid host can.
|
||||
/// pair of pinned paths, a pair of forwards and a Spelunk route that signs in — which are orthogonal to the
|
||||
/// binding (a sign-in leaves a stored binding alone rather than refusing it) and
|
||||
/// are what make this host reach the highest schema version a valid host can. The route and the relay are
|
||||
/// exclusive in the same way the bindings are, so this carries the newer of those two as well; the relay's
|
||||
/// flag has its own tests.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void AFullHost_RoundTrips()
|
||||
@@ -34,12 +37,12 @@ public sealed class HostSecretCodecTests
|
||||
notes: "primary replica",
|
||||
jumps: [Bastion, Relay],
|
||||
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
|
||||
relayEnabled: true,
|
||||
credentialId: credentialId,
|
||||
groupId: Production,
|
||||
tags: [Pci, EuWest],
|
||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||
portForwards: [Postgres, Socks]);
|
||||
portForwards: [Postgres, Socks],
|
||||
spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
@@ -50,6 +53,21 @@ public sealed class HostSecretCodecTests
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARelayedHost_RoundTrips_AtTheVersionItsOtherFieldsNeed()
|
||||
{
|
||||
// The relay flag predates schema versions, so it costs none; it is here because the full host above
|
||||
// now takes the Spelunk route, which the relay excludes.
|
||||
var host = Host(relayEnabled: true);
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.Host.RelayEnabled.ShouldBeTrue();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
|
||||
}
|
||||
|
||||
// ---- The schema version is content-dependent ----
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
using System.Text;
|
||||
using static DodoSSH.Client.Domain.Tests.HostFactory;
|
||||
|
||||
namespace DodoSSH.Client.Domain.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// A host reached through Spelunk: the route's own rules, how it is stored, and how it merges.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The weight is on the compatibility rules, because a Spelunk host misread by some build is a host dialled
|
||||
/// directly at an address that exists only to name its key — the one misreading that does something the
|
||||
/// user never asked for.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkRouteTests
|
||||
{
|
||||
// ---- The API address ----
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://api.spelunk.example.com", "https://api.spelunk.example.com")]
|
||||
[InlineData(" https://API.Spelunk.Example.com/ ", "https://api.spelunk.example.com")]
|
||||
[InlineData("https://api.spelunk.example.com:8443/", "https://api.spelunk.example.com:8443")]
|
||||
[InlineData("https://example.com/spelunk/", "https://example.com/spelunk")]
|
||||
[InlineData("http://localhost:5080", "http://localhost:5080")]
|
||||
[InlineData("http://127.0.0.1:5080/", "http://127.0.0.1:5080")]
|
||||
public void AnApiAddress_IsStoredInOneForm(string typed, string stored)
|
||||
{
|
||||
// One form, so the host a link finds is the host that was saved for it however either was typed.
|
||||
SpelunkRoute.TryNormalizeApiBase(typed, out var normalized, out _).ShouldBeTrue();
|
||||
|
||||
normalized.ShouldBe(stored);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("api.spelunk.example.com")]
|
||||
[InlineData("ftp://api.spelunk.example.com")]
|
||||
[InlineData("javascript:alert(1)")]
|
||||
[InlineData("https://user:pass@api.spelunk.example.com")]
|
||||
[InlineData("https://api.spelunk.example.com/?x=1")]
|
||||
[InlineData("https://api.spelunk.example.com/#top")]
|
||||
public void AnAddressThatIsNotASpelunkApi_IsRefused(string typed)
|
||||
{
|
||||
SpelunkRoute.TryNormalizeApiBase(typed, out _, out var reason).ShouldBeFalse();
|
||||
|
||||
reason.ShouldNotBeNullOrWhiteSpace();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PlainHttp_IsRefusedAnywhereButThisMachine()
|
||||
{
|
||||
// The key is a bearer token sent with every tunnel; in clear across a network it is somebody else's.
|
||||
SpelunkRoute.TryNormalizeApiBase("http://api.spelunk.example.com", out _, out var reason)
|
||||
.ShouldBeFalse();
|
||||
|
||||
reason.ShouldNotBeNull().ShouldContain("https");
|
||||
}
|
||||
|
||||
// ---- The host ----
|
||||
|
||||
[Fact]
|
||||
public void AHostReachedThroughSpelunk_IsValid()
|
||||
{
|
||||
Host(spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeTrue(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostCannotTakeTheRelayAndSpelunkBoth()
|
||||
{
|
||||
Host(relayEnabled: true, spelunk: ThroughSpelunk()).TryValidate(out var reason).ShouldBeFalse();
|
||||
|
||||
reason.ShouldNotBeNull().ShouldContain("not both");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("server")]
|
||||
[InlineData("key")]
|
||||
[InlineData("api")]
|
||||
public void ARouteMissingAPart_IsRefused(string missing)
|
||||
{
|
||||
var route = missing switch
|
||||
{
|
||||
"server" => ThroughSpelunk() with { ServerId = Guid.Empty },
|
||||
"key" => ThroughSpelunk() with { ApiKeyCredentialId = Guid.Empty },
|
||||
_ => ThroughSpelunk() with { ApiBase = "https://api.spelunk.example.com/" },
|
||||
};
|
||||
|
||||
Host(spelunk: route).TryValidate(out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
// ---- The payload ----
|
||||
|
||||
[Fact]
|
||||
public void AHostReachedThroughSpelunk_RoundTrips_AtTheVersionThatIntroducedIt()
|
||||
{
|
||||
var host = Host(spelunk: ThroughSpelunk(), credentialId: Guid.CreateVersion7());
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostWithoutARoute_EncodesExactlyAsBefore()
|
||||
{
|
||||
// Not using the feature is not using the feature: no key in the JSON, and no version bump, so every
|
||||
// existing host keeps its bytes and the first sync after the upgrade pushes nothing.
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null));
|
||||
|
||||
Encoding.UTF8.GetString(bytes).ShouldBe(
|
||||
"""
|
||||
{"schemaVersion":1,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheRoute_IsWrittenLastAndWhole()
|
||||
{
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk()));
|
||||
|
||||
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"}}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOlderPayload_StillDecodes_AsAHostThatIsDialled()
|
||||
{
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":7,"label":"prod-db","hostname":"db.internal","port":22,"pinnedPaths":["/var/log"]}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBeNull();
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHalfWrittenRoute_MakesTheHostUnreadable_RatherThanDialled()
|
||||
{
|
||||
// The other reading — no route — would turn a damaged Spelunk host into one dialled directly.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com"}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARouteWrittenByANewerClient_IsReadOnlyHere()
|
||||
{
|
||||
// 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.
|
||||
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}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue();
|
||||
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public void AHostSpelunkSignsInTo_IsValid_WithNoLoginOfItsOwn()
|
||||
{
|
||||
// No username, no key, no credential, no typed password: Spelunk names the account and makes the key.
|
||||
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
host.TryValidate(out var reason).ShouldBeTrue(reason);
|
||||
host.SignsInThroughSpelunk.ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostSpelunkSignsInTo_KeepsALoginItStillCarries()
|
||||
{
|
||||
// Left alone rather than refused: it is the host's answer for the day the box is unticked, and a
|
||||
// merge of one machine ticking it with another binding a key must still decode.
|
||||
Host(sshKeyId: Guid.CreateVersion7(), spelunk: ThroughSpelunk(signIn: true))
|
||||
.TryValidate(out var reason)
|
||||
.ShouldBeTrue(reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostReachedThroughSpelunkWithoutSigningIn_DoesNotSignIn()
|
||||
{
|
||||
Host(spelunk: ThroughSpelunk()).SignsInThroughSpelunk.ShouldBeFalse();
|
||||
Host().SignsInThroughSpelunk.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignIn_RoundTrips_AtTheVersionThatIntroducedIt()
|
||||
{
|
||||
var host = Host(username: null, spelunk: ThroughSpelunk(signIn: true));
|
||||
|
||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull();
|
||||
document.Host.ShouldBe(host);
|
||||
document.Host.Spelunk.ShouldNotBeNull().SignInThroughSpelunk.ShouldBeTrue();
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.SpelunkSignInSchemaVersion);
|
||||
document.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignIn_IsWrittenInsideTheRoute_AndOnlyWhenOn()
|
||||
{
|
||||
var bytes = HostSecretCodec.Encode(Host(username: null, notes: null, spelunk: ThroughSpelunk(signIn: true)));
|
||||
|
||||
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}}
|
||||
""");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ARouteWrittenBeforeSignIn_DecodesAsNotSigningIn_AndStaysEditable()
|
||||
{
|
||||
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"}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
document.Host.SignsInThroughSpelunk.ShouldBeFalse();
|
||||
document.IsReadOnly.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignInWrittenAsFalse_ReadsAsTheRouteWithout()
|
||||
{
|
||||
// One state, one spelling — the merge would otherwise see a change nobody made.
|
||||
var payload = Encoding.UTF8.GetBytes(
|
||||
"""
|
||||
{"schemaVersion":10,"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","signIn":false}}
|
||||
""");
|
||||
|
||||
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||
|
||||
document.ShouldNotBeNull().Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TickingSignIn_MergesAsPartOfTheOneRoute()
|
||||
{
|
||||
var ancestor = Host(spelunk: ThroughSpelunk());
|
||||
var local = ancestor with { Spelunk = ThroughSpelunk(signIn: true) };
|
||||
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
|
||||
|
||||
// Alone, the tick arrives.
|
||||
HostSecretMerge.Merge(ancestor, local, ancestor).Merged.ShouldBe(local);
|
||||
|
||||
// Beside a different edit to the route, the route is still one value: one side wins whole, with the
|
||||
// other reported, rather than a sign-in to a server nobody chose it for.
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
|
||||
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
|
||||
}
|
||||
|
||||
// ---- The merge ----
|
||||
|
||||
[Fact]
|
||||
public void TheRoute_IsRoutedThroughTheMerge()
|
||||
{
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Spelunk = ThroughSpelunk() };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, ancestor);
|
||||
|
||||
result.Merged.ShouldBe(local);
|
||||
result.HasConflicts.ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TwoDifferentRoutes_MergeAsOneValue_AndTheDiscardedOneIsReported()
|
||||
{
|
||||
// Whole, not per part: one side's API address with the other side's server id is a route nobody
|
||||
// chose, to a server that may not exist on that Spelunk.
|
||||
var ancestor = Host();
|
||||
var local = ancestor with { Spelunk = ThroughSpelunk("https://spelunk.local.example") };
|
||||
var remote = ancestor with { Spelunk = ThroughSpelunk() with { ServerId = Guid.CreateVersion7() } };
|
||||
|
||||
var result = HostSecretMerge.Merge(ancestor, local, remote);
|
||||
|
||||
result.Merged.Spelunk.ShouldBe(remote.Spelunk);
|
||||
result.Conflicts.ShouldHaveSingleItem().Field.ShouldBe(nameof(HostSecret.Spelunk));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Spelunk's two tunnel endpoints, as far as the wire is concerned: HTTP/1.1 over a real loopback socket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Real sockets rather than a stubbed handler, because what is being tested is the client's own HTTP and
|
||||
/// WebSocket stack talking to something: the header the key goes in, the subprotocols the upgrade offers,
|
||||
/// a refused upgrade's status read back through <c>CollectHttpResponseDetails</c>, and a close code
|
||||
/// arriving through <see cref="Client.Ssh.RelayBridge"/>. A handler stub would skip exactly those.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Hand-rolled rather than Kestrel, so this client suite takes no dependency on the ASP.NET Core framework
|
||||
/// for one fixture. It speaks only what the client sends: one request per connection, answered with
|
||||
/// <c>Connection: close</c>, and the one upgrade.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class FakeSpelunk : IAsyncDisposable
|
||||
{
|
||||
internal const string Ticket = "dGlja2V0LWZvci1hLXR1bm5lbA";
|
||||
|
||||
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
|
||||
private readonly CancellationTokenSource stopping = new();
|
||||
private readonly Task accepting;
|
||||
|
||||
internal FakeSpelunk()
|
||||
{
|
||||
listener.Start();
|
||||
accepting = AcceptAsync();
|
||||
}
|
||||
|
||||
/// <summary>The API base a client is given.</summary>
|
||||
internal string ApiBase => $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}";
|
||||
|
||||
/// <summary>Every request that arrived, in order.</summary>
|
||||
internal List<Request> Requests { get; } = [];
|
||||
|
||||
/// <summary>How the ticket endpoint answers. By default, with a ticket.</summary>
|
||||
internal Func<Request, Answer> OnTicket { get; set; } = request => Answer.Json(
|
||||
HttpStatusCode.OK,
|
||||
$$"""
|
||||
{"ticket":"{{Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1"}
|
||||
""");
|
||||
|
||||
/// <summary>How the upgrade is answered: null accepts it, anything else refuses it with that status.</summary>
|
||||
internal Func<Request, HttpStatusCode?> OnUpgrade { get; set; } = _ => null;
|
||||
|
||||
/// <summary>What Spelunk does with an accepted tunnel — by default nothing, until the client closes.</summary>
|
||||
internal Func<WebSocket, Task> OnTunnel { get; set; } = DrainAsync;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await stopping.CancelAsync();
|
||||
listener.Stop();
|
||||
|
||||
try
|
||||
{
|
||||
await accepting;
|
||||
}
|
||||
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
|
||||
{
|
||||
// Stopped.
|
||||
}
|
||||
|
||||
stopping.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>Reads until the client closes, and answers the close.</summary>
|
||||
internal static async Task DrainAsync(WebSocket socket)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
while ((await socket.ReceiveAsync(buffer, CancellationToken.None)).MessageType != WebSocketMessageType.Close)
|
||||
{
|
||||
}
|
||||
|
||||
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task AcceptAsync()
|
||||
{
|
||||
while (!stopping.IsCancellationRequested)
|
||||
{
|
||||
var client = await listener.AcceptTcpClientAsync(stopping.Token);
|
||||
_ = ServeAsync(client);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ServeAsync(TcpClient client)
|
||||
{
|
||||
using (client)
|
||||
{
|
||||
var stream = client.GetStream();
|
||||
var request = await ReadRequestAsync(stream);
|
||||
|
||||
lock (Requests)
|
||||
{
|
||||
Requests.Add(request);
|
||||
}
|
||||
|
||||
if (request.Headers.TryGetValue("Upgrade", out var upgrade)
|
||||
&& upgrade.Equals("websocket", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await UpgradeAsync(stream, request);
|
||||
return;
|
||||
}
|
||||
|
||||
var answer = OnTicket(request);
|
||||
var body = Encoding.UTF8.GetBytes(answer.Body);
|
||||
|
||||
await WriteAsync(
|
||||
stream,
|
||||
$"HTTP/1.1 {(int)answer.Status} {answer.Status}\r\nContent-Type: {answer.ContentType}\r\n"
|
||||
+ $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n");
|
||||
await stream.WriteAsync(body);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpgradeAsync(NetworkStream stream, Request request)
|
||||
{
|
||||
if (OnUpgrade(request) is { } refusal)
|
||||
{
|
||||
await WriteAsync(
|
||||
stream,
|
||||
$"HTTP/1.1 {(int)refusal} {refusal}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteAsync(
|
||||
stream,
|
||||
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
|
||||
+ $"Sec-WebSocket-Accept: {AcceptFor(request.Headers["Sec-WebSocket-Key"])}\r\n"
|
||||
+ "Sec-WebSocket-Protocol: spelunk.tunnel.v1\r\n\r\n");
|
||||
|
||||
using var socket = WebSocket.CreateFromStream(
|
||||
stream,
|
||||
new WebSocketCreationOptions
|
||||
{
|
||||
IsServer = true,
|
||||
SubProtocol = "spelunk.tunnel.v1",
|
||||
KeepAliveInterval = TimeSpan.Zero,
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
await OnTunnel(socket);
|
||||
}
|
||||
catch (Exception exception) when (exception is WebSocketException or IOException)
|
||||
{
|
||||
// The client went away; nothing here is under test.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The handshake's accept value, as RFC 6455 section 4.2.2 defines it.</summary>
|
||||
private static string AcceptFor(string key)
|
||||
{
|
||||
#pragma warning disable RS0030, CA5350 // RFC 6455 fixes SHA-1 for this value. It authenticates nothing; it proves the peer read the key.
|
||||
return Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
|
||||
#pragma warning restore RS0030, CA5350
|
||||
}
|
||||
|
||||
private static async Task<Request> ReadRequestAsync(NetworkStream stream)
|
||||
{
|
||||
var head = new List<byte>();
|
||||
var one = new byte[1];
|
||||
|
||||
while (!(head.Count >= 4 && head[^4] == '\r' && head[^3] == '\n' && head[^2] == '\r' && head[^1] == '\n'))
|
||||
{
|
||||
if (await stream.ReadAsync(one) == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
head.Add(one[0]);
|
||||
}
|
||||
|
||||
var lines = Encoding.ASCII.GetString([.. head]).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
|
||||
var requestLine = lines[0].Split(' ');
|
||||
var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
var colon = line.IndexOf(':', StringComparison.Ordinal);
|
||||
headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
|
||||
}
|
||||
|
||||
var body = string.Empty;
|
||||
|
||||
if (headers.TryGetValue("Content-Length", out var length) && int.Parse(length, System.Globalization.CultureInfo.InvariantCulture) is > 0 and var count)
|
||||
{
|
||||
var bytes = new byte[count];
|
||||
await stream.ReadExactlyAsync(bytes);
|
||||
body = Encoding.UTF8.GetString(bytes);
|
||||
}
|
||||
|
||||
return new Request(requestLine[0], requestLine[1], headers, body);
|
||||
}
|
||||
|
||||
private static Task WriteAsync(NetworkStream stream, string text) =>
|
||||
stream.WriteAsync(Encoding.ASCII.GetBytes(text)).AsTask();
|
||||
|
||||
/// <summary>One request as it arrived.</summary>
|
||||
internal sealed record Request(string Method, string Path, Dictionary<string, string> Headers, string Body);
|
||||
|
||||
/// <summary>What the ticket endpoint says back.</summary>
|
||||
internal sealed record Answer(HttpStatusCode Status, string ContentType, string Body)
|
||||
{
|
||||
internal static Answer Json(HttpStatusCode status, string body) => new(status, "application/json", body);
|
||||
|
||||
internal static Answer Problem(HttpStatusCode status, string title, string? detail = null) => new(
|
||||
status,
|
||||
"application/problem+json",
|
||||
detail is null
|
||||
? $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}}}"""
|
||||
: $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}},"detail":"{{detail}}"}""");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Reading a <c>dodossh://spelunk/connect</c> link, which anything on the machine can hand the application.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A link is data to check, never an instruction. The refusals below are the part that matters: each is a
|
||||
/// link that is nearly right, and "nearly right" is the shape a crafted one takes.
|
||||
/// </remarks>
|
||||
public sealed class SpelunkLinkTests
|
||||
{
|
||||
private const string Server = "0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b";
|
||||
|
||||
private const string Link =
|
||||
"dodossh://spelunk/connect?api=https%3A%2F%2Fapi.spelunk.example.com&server=" + Server
|
||||
+ "&name=web%2001&host=203.0.113.10";
|
||||
|
||||
[Fact]
|
||||
public void ALinkFromSpelunk_IsRead()
|
||||
{
|
||||
SpelunkLink.TryParse(Link, out var link).ShouldBeTrue();
|
||||
|
||||
link.ShouldBe(new SpelunkLink("https://api.spelunk.example.com", Guid.Parse(Server), "web 01", "203.0.113.10"));
|
||||
link.Hostname.ShouldBe("203.0.113.10");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALinkWithNoHost_NamesTheHostAfterTheServer()
|
||||
{
|
||||
// The name the host key is pinned under, so it has to be stable and this server's alone.
|
||||
SpelunkLink.TryParse(Link.Replace("&host=203.0.113.10", string.Empty, StringComparison.Ordinal), out var link)
|
||||
.ShouldBeTrue();
|
||||
|
||||
link.Host.ShouldBeNull();
|
||||
link.Hostname.ShouldBe($"spelunk-{Server}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALink_WritesOutAsItReadsBack()
|
||||
{
|
||||
// What a second launch sends the running instance: the one spelling this build writes.
|
||||
SpelunkLink.TryParse(Link, out var link).ShouldBeTrue();
|
||||
|
||||
SpelunkLink.TryParse(link.ToString(), out var again).ShouldBeTrue();
|
||||
|
||||
again.ShouldBe(link);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANameFormEncoded_AndOneWithControlCharacters_AreCleanedForDisplay()
|
||||
{
|
||||
SpelunkLink.TryParse(Link.Replace("web%2001", "web+01%0A%1B%5B31m", StringComparison.Ordinal), out var link)
|
||||
.ShouldBeTrue();
|
||||
|
||||
link.Name.ShouldNotContain('\n');
|
||||
link.Name.ShouldNotContain('\u001b');
|
||||
link.Name.ShouldStartWith("web 01");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnUnknownParameter_IsIgnored()
|
||||
{
|
||||
// So a newer Spelunk can add one without every DodoSSH already installed refusing its links.
|
||||
SpelunkLink.TryParse(Link + "&port=22", out _).ShouldBeTrue();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://elsewhere/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/disconnect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://user@spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk:8080/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "#x")]
|
||||
[InlineData("dodossh://spelunk/connect?server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com")]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=not-a-guid")]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=00000000-0000-0000-0000-000000000000")]
|
||||
[InlineData("dodossh://spelunk/connect?api=http%3A%2F%2Fapi.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fu%3Ap%40api.example.com&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&api=https%3A%2F%2Fevil.example&server=" + Server)]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "&host=bad%20host")]
|
||||
[InlineData("dodossh://spelunk/connect?api=https%3A%2F%2Fapi.example.com&server=" + Server + "&name=a b")]
|
||||
[InlineData("")]
|
||||
public void ALinkThatIsNotExactlyThis_IsRefused(string text)
|
||||
{
|
||||
SpelunkLink.TryParse(text, out var link).ShouldBeFalse();
|
||||
|
||||
link.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnOverlongLink_IsRefusedBeforeItIsParsed()
|
||||
{
|
||||
SpelunkLink.TryParse(Link + "&name=" + new string('a', SpelunkLink.MaxLength), out _).ShouldBeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ANameThatRunsOn_IsCut()
|
||||
{
|
||||
SpelunkLink.TryParse(Link.Replace("web%2001", new string('a', 500), StringComparison.Ordinal), out var link)
|
||||
.ShouldBeTrue();
|
||||
|
||||
link.Name.Length.ShouldBe(SpelunkLink.MaxNameLength);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("dodossh://spelunk/connect", true)]
|
||||
[InlineData("DODOSSH://anything", true)]
|
||||
[InlineData("--veloapp-install", false)]
|
||||
[InlineData(null, false)]
|
||||
public void TheLauncher_TellsALinkFromAnotherArgument(string? argument, bool looksLikeOne)
|
||||
{
|
||||
SpelunkLink.LooksLikeOne(argument).ShouldBe(looksLikeOne);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
|
||||
namespace DodoSSH.Client.Session.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The Spelunk tunnel client against Spelunk's contract, over real sockets.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every test here uses the application's own <see cref="SpelunkTunnel"/> — its shared transport and its
|
||||
/// real WebSocket dialler — against <see cref="FakeSpelunk"/>. What is asserted is what the contract fixes:
|
||||
/// the ticket request, the two subprotocols, bytes both ways once the tunnel is up, and a sentence for every
|
||||
/// way Spelunk says no.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The bytes are not SSH here. The bridge is the relay's, and a real SSH connection through it is
|
||||
/// <c>RelayBridgeTests</c>' job; this suite holds the half that is Spelunk's.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class SpelunkTunnelTests : IAsyncLifetime
|
||||
{
|
||||
private const string ApiKey = "spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG";
|
||||
|
||||
private static readonly Guid ServerId = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
|
||||
|
||||
private static readonly TimeSpan Settles = TimeSpan.FromSeconds(20);
|
||||
|
||||
private readonly FakeSpelunk spelunk = new();
|
||||
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
public ValueTask InitializeAsync() => ValueTask.CompletedTask;
|
||||
|
||||
public ValueTask DisposeAsync() => spelunk.DisposeAsync();
|
||||
|
||||
[Fact]
|
||||
public async Task ATicket_IsAskedForWithTheKey_AndSpentAsTheSecondSubprotocol()
|
||||
{
|
||||
await using var route = await OpenAsync();
|
||||
|
||||
var ticket = spelunk.Requests[0];
|
||||
ticket.Method.ShouldBe("POST");
|
||||
ticket.Path.ShouldBe($"/api/servers/{ServerId}/tunnel-tickets");
|
||||
ticket.Headers["Authorization"].ShouldBe($"Bearer {ApiKey}");
|
||||
ticket.Body.ShouldBe("""{"port":22}""");
|
||||
|
||||
var upgrade = spelunk.Requests[1];
|
||||
upgrade.Path.ShouldBe($"/api/servers/{ServerId}/tunnel");
|
||||
upgrade.Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
|
||||
|
||||
// The key opened the ticket and went nowhere else: the socket carries the ticket alone.
|
||||
upgrade.Headers.ContainsKey("Authorization").ShouldBeFalse();
|
||||
upgrade.Path.ShouldNotContain(FakeSpelunk.Ticket);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BytesCross_BothWays_ThroughTheBridge()
|
||||
{
|
||||
var received = new TaskCompletionSource<string>();
|
||||
|
||||
spelunk.OnTunnel = async socket =>
|
||||
{
|
||||
// What the agent's end does: read what the client sent, answer with the server's own bytes.
|
||||
var buffer = new byte[64];
|
||||
var read = await socket.ReceiveAsync(buffer, CancellationToken.None);
|
||||
received.TrySetResult(Encoding.ASCII.GetString(buffer, 0, read.Count));
|
||||
|
||||
await socket.SendAsync(
|
||||
Encoding.ASCII.GetBytes("SSH-2.0-OpenSSH_9.9\r\n"),
|
||||
WebSocketMessageType.Binary,
|
||||
endOfMessage: true,
|
||||
CancellationToken.None);
|
||||
|
||||
await FakeSpelunk.DrainAsync(socket);
|
||||
};
|
||||
|
||||
await using var route = await OpenAsync();
|
||||
using var client = await ThroughSocksAsync(route.Proxy.Port);
|
||||
var stream = client.GetStream();
|
||||
|
||||
await stream.WriteAsync(Encoding.ASCII.GetBytes("SSH-2.0-DodoSSH\r\n"), Token);
|
||||
(await received.Task.WaitAsync(Settles, Token)).ShouldBe("SSH-2.0-DodoSSH\r\n");
|
||||
|
||||
var answer = new byte["SSH-2.0-OpenSSH_9.9\r\n".Length];
|
||||
await stream.ReadExactlyAsync(answer, Token);
|
||||
Encoding.ASCII.GetString(answer).ShouldBe("SSH-2.0-OpenSSH_9.9\r\n");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ThePortAsked_IsTheHostsPort()
|
||||
{
|
||||
await using var route = await spelunk.ApiBase.OpenAsync(port: 2222);
|
||||
|
||||
spelunk.Requests[0].Body.ShouldBe("""{"port":2222}""");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized, "did not accept the API key")]
|
||||
[InlineData(HttpStatusCode.NotFound, "does not know this server")]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
|
||||
[InlineData(HttpStatusCode.InternalServerError, "HTTP 500")]
|
||||
public async Task ARefusedTicket_IsExplained_AndNoSocketIsOpened(HttpStatusCode status, string explanation)
|
||||
{
|
||||
spelunk.OnTicket = _ => FakeSpelunk.Answer.Problem(status, "refused");
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
|
||||
|
||||
refusal.Message.ShouldContain(explanation);
|
||||
refusal.StatusCode.ShouldBe(status);
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeyWithTooLowARole_IsToldWhatSpelunkSaid()
|
||||
{
|
||||
// Spelunk's detail says which role the key has and which it needs, and it is the useful half.
|
||||
spelunk.OnTicket = _ => FakeSpelunk.Answer.Problem(
|
||||
HttpStatusCode.Forbidden,
|
||||
"Forbidden",
|
||||
"This API key has the Viewer role; opening a tunnel needs Admin.");
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
|
||||
|
||||
refusal.Message.ShouldContain("not allowed to open a terminal");
|
||||
refusal.Message.ShouldContain("needs Admin");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(HttpStatusCode.Unauthorized, "refused the tunnel ticket")]
|
||||
[InlineData(HttpStatusCode.ServiceUnavailable, "agent on this server is not connected")]
|
||||
public async Task ARefusedUpgrade_IsExplained(HttpStatusCode status, string explanation)
|
||||
{
|
||||
spelunk.OnUpgrade = _ => status;
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
|
||||
|
||||
refusal.Message.ShouldContain(explanation);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("//evil.example/api/servers/x/tunnel")]
|
||||
[InlineData("@evil.example/api/servers/x/tunnel")]
|
||||
[InlineData("api/servers/x/tunnel")]
|
||||
[InlineData("/api/servers/x/tunnel?elsewhere=1")]
|
||||
public async Task ATicketNamingAnythingButAPath_IsRefused_AndNothingIsDialled(string path)
|
||||
{
|
||||
// The path is appended to the API's own origin. Anything that would move it to another host — and
|
||||
// take the ticket with it — is not a path.
|
||||
spelunk.OnTicket = _ => FakeSpelunk.Answer.Json(
|
||||
HttpStatusCode.OK,
|
||||
$$"""{"ticket":"{{FakeSpelunk.Ticket}}","path":"{{path}}","subprotocol":"spelunk.tunnel.v1"}""");
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(OpenAsync);
|
||||
|
||||
refusal.Message.ShouldContain("does not understand");
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("connection refused", "could not connect to port 22 on the server itself (connection refused)")]
|
||||
[InlineData("Unknown stream kind: tunnel", "too old to open a terminal")]
|
||||
public async Task AnAgentThatCouldNotReachThePort_SaysSo(string reason, string explanation)
|
||||
{
|
||||
spelunk.OnTunnel = socket =>
|
||||
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
|
||||
|
||||
var route = await OpenAsync();
|
||||
|
||||
await EndOfStreamAsync(route);
|
||||
|
||||
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnAgentThatDisconnected_SaysSo()
|
||||
{
|
||||
spelunk.OnTunnel = socket =>
|
||||
socket.CloseOutputAsync((WebSocketCloseStatus)4503, "agent gone", CancellationToken.None);
|
||||
|
||||
var route = await OpenAsync();
|
||||
|
||||
await EndOfStreamAsync(route);
|
||||
|
||||
route.Failure.ShouldNotBeNull().ShouldContain("disconnected from Spelunk");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ANormalClose_IsNotReworded()
|
||||
{
|
||||
// 1000 is the SSH server hanging up, and SSH.NET's own account of that is the accurate one.
|
||||
spelunk.OnTunnel = socket =>
|
||||
socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
|
||||
|
||||
var route = await OpenAsync();
|
||||
|
||||
await EndOfStreamAsync(route);
|
||||
|
||||
route.Failure.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeyWithALineBreakInTheMiddle_IsRefusedBeforeAnythingIsSent()
|
||||
{
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
new SpelunkTunnel().OpenAsync(spelunk.ApiBase, ServerId, "spelunk_abc\ndef", 22, signIn: null, Token));
|
||||
|
||||
refusal.Message.ShouldContain("does not hold one");
|
||||
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]
|
||||
public async Task AnUnreachableSpelunk_SaysWhere()
|
||||
{
|
||||
// A port that was listening a moment ago and is not any more.
|
||||
var closed = new TcpListener(IPAddress.Loopback, 0);
|
||||
closed.Start();
|
||||
var port = ((IPEndPoint)closed.LocalEndpoint).Port;
|
||||
closed.Stop();
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
new SpelunkTunnel().OpenAsync($"http://127.0.0.1:{port}", ServerId, ApiKey, 22, signIn: null, Token));
|
||||
|
||||
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
|
||||
}
|
||||
|
||||
// ---- Signing in through Spelunk ----
|
||||
|
||||
[Fact]
|
||||
public async Task ASignIn_SendsThePublicKeyAndNoPort_AndLogsInAsTheAccountSpelunkNames()
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs("root");
|
||||
var signIn = SpelunkSignIn.Create();
|
||||
|
||||
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, signIn);
|
||||
|
||||
// The one-line OpenSSH public key and nothing else: the port means nothing beside a key.
|
||||
// Read as JSON rather than compared as text: the serialiser escapes base64's '+' as +, which is
|
||||
// the same string to anything that parses it.
|
||||
using (var body = System.Text.Json.JsonDocument.Parse(spelunk.Requests[0].Body))
|
||||
{
|
||||
body.RootElement.EnumerateObject().Select(property => property.Name).ShouldBe(["signIn"]);
|
||||
var sent = body.RootElement.GetProperty("signIn");
|
||||
sent.EnumerateObject().Select(property => property.Name).ShouldBe(["publicKey"]);
|
||||
sent.GetProperty("publicKey").GetString().ShouldBe(signIn.PublicKeyLine);
|
||||
}
|
||||
|
||||
signIn.PublicKeyLine.ShouldStartWith("ssh-ed25519 AAAA");
|
||||
|
||||
// The same key comes back with the route, for the connection made through it, under Spelunk's account.
|
||||
route.SignIn.ShouldBeSameAs(signIn);
|
||||
route.User.ShouldBe("root");
|
||||
|
||||
// And the socket is spent exactly as a plain tunnel's is.
|
||||
spelunk.Requests[1].Headers["Sec-WebSocket-Protocol"].ShouldBe($"spelunk.tunnel.v1, ticket.{FakeSpelunk.Ticket}");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASignInAnswerNamingNoAccount_LogsInAsRoot()
|
||||
{
|
||||
await using var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
|
||||
|
||||
route.User.ShouldBe(SpelunkSignIn.DefaultUser);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task APlainTunnel_HasNoSignIn_WhateverTheAnswerSays()
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs("root");
|
||||
|
||||
await using var route = await OpenAsync();
|
||||
|
||||
route.SignIn.ShouldBeNull();
|
||||
route.User.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("root; rm -rf /")]
|
||||
[InlineData("root\\nsomeone")]
|
||||
[InlineData("")]
|
||||
public async Task ASignInAnswerNamingSomethingThatIsNotAnAccount_IsRefused(string user)
|
||||
{
|
||||
spelunk.OnTicket = SignedInAs(user);
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
|
||||
|
||||
refusal.Message.ShouldContain("does not understand");
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeySpelunkWillNotTake_IsExplainedInItsWords()
|
||||
{
|
||||
spelunk.OnTicket = _ => new FakeSpelunk.Answer(
|
||||
HttpStatusCode.BadRequest,
|
||||
"application/problem+json",
|
||||
"""{"title":"One or more validation errors occurred.","status":400,"errors":{"publicKey":["The public key must be an ssh-ed25519 key."]}}""");
|
||||
|
||||
var refusal = await Should.ThrowAsync<SpelunkTunnelException>(
|
||||
spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create()));
|
||||
|
||||
refusal.StatusCode.ShouldBe(HttpStatusCode.BadRequest);
|
||||
refusal.Message.ShouldContain("would not accept");
|
||||
refusal.Message.ShouldContain("must be an ssh-ed25519 key");
|
||||
spelunk.Requests.ShouldHaveSingleItem();
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("no OpenSSH server on this machine", "could not sign you in on this server (no OpenSSH server on this machine)")]
|
||||
[InlineData("Unknown stream kind: signIn", "too old to sign you in")]
|
||||
public async Task AnAgentThatCouldNotSignIn_SaysSo(string reason, string explanation)
|
||||
{
|
||||
spelunk.OnTunnel = socket =>
|
||||
socket.CloseOutputAsync((WebSocketCloseStatus)4502, reason, CancellationToken.None);
|
||||
|
||||
var route = await spelunk.ApiBase.OpenAsync(port: 22, SpelunkSignIn.Create());
|
||||
|
||||
await EndOfStreamAsync(route);
|
||||
|
||||
route.Failure.ShouldNotBeNull().ShouldContain(explanation);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASignInKey_IsNewEachTime_AndNeverPrinted()
|
||||
{
|
||||
var first = SpelunkSignIn.Create();
|
||||
var second = SpelunkSignIn.Create();
|
||||
|
||||
first.PublicKeyLine.ShouldNotBe(second.PublicKeyLine);
|
||||
first.PublicKeyLine.Split(' ').Length.ShouldBe(3);
|
||||
first.Credential.Passphrase.ShouldBeNull();
|
||||
Encoding.UTF8.GetString(first.Credential.PrivateKeyPem).ShouldStartWith("-----BEGIN OPENSSH PRIVATE KEY-----");
|
||||
|
||||
first.ToString().ShouldNotContain("AAAA");
|
||||
first.ToString().ShouldNotContain("PRIVATE");
|
||||
}
|
||||
|
||||
/// <summary>A ticket answer for a sign-in, naming the account it logs in as.</summary>
|
||||
private static Func<FakeSpelunk.Request, FakeSpelunk.Answer> SignedInAs(string user) => request => FakeSpelunk.Answer.Json(
|
||||
HttpStatusCode.OK,
|
||||
$$"""
|
||||
{"ticket":"{{FakeSpelunk.Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1","user":"{{user}}"}
|
||||
""");
|
||||
|
||||
private Task<SpelunkTunnelRoute> OpenAsync() => spelunk.ApiBase.OpenAsync(port: 22);
|
||||
|
||||
/// <summary>Speaks SOCKS5 to the bridge as SSH.NET would, and hands back the open stream.</summary>
|
||||
private static async Task<TcpClient> ThroughSocksAsync(int port)
|
||||
{
|
||||
var client = new TcpClient();
|
||||
await client.ConnectAsync(IPAddress.Loopback, port, Token);
|
||||
|
||||
var stream = client.GetStream();
|
||||
var name = Encoding.ASCII.GetBytes("203.0.113.10");
|
||||
|
||||
await stream.WriteAsync(new byte[] { 5, 1, 0 }, Token);
|
||||
await stream.ReadExactlyAsync(new byte[2], Token);
|
||||
|
||||
await stream.WriteAsync((byte[])[5, 1, 0, 3, (byte)name.Length, .. name, 0, 22], Token);
|
||||
await stream.ReadExactlyAsync(new byte[10], Token);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
/// <summary>Arrives at the bridge and reads until it closes the stream, which it does once Spelunk has.</summary>
|
||||
private static async Task EndOfStreamAsync(SpelunkTunnelRoute route)
|
||||
{
|
||||
using var client = await ThroughSocksAsync(route.Proxy.Port);
|
||||
var stream = client.GetStream();
|
||||
var rest = new byte[256];
|
||||
|
||||
while (await stream.ReadAsync(rest, Token).AsTask().WaitAsync(Settles, Token) > 0)
|
||||
{
|
||||
}
|
||||
|
||||
await route.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Opens a tunnel to the test server with the one key and server the suite uses.</summary>
|
||||
file static class Opening
|
||||
{
|
||||
internal static Task<SpelunkTunnelRoute> OpenAsync(this string apiBase, int port, SpelunkSignIn? signIn = null) =>
|
||||
new SpelunkTunnel().OpenAsync(
|
||||
apiBase,
|
||||
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||
port,
|
||||
signIn,
|
||||
TestContext.Current.CancellationToken);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Sync.Tests;
|
||||
@@ -53,4 +54,23 @@ public sealed class ItemKindsTests
|
||||
|
||||
reconcilers.Keys.Order().ShouldBe(ItemKinds.SyncedTypes.Order());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AHostEditThatOnlyChangedItsPins_IsReportedAsChangingPinnedPaths()
|
||||
{
|
||||
// Pinned paths were the one host field Changes did not look at, so an edit that only pinned or
|
||||
// unpinned a directory reached the keychain log as an update that changed nothing.
|
||||
var before = new HostSecret
|
||||
{
|
||||
Label = "db-01",
|
||||
Hostname = "db-01.internal",
|
||||
PinnedPaths = PinnedPathList.Create(["/var/log"]),
|
||||
};
|
||||
var after = before with { PinnedPaths = PinnedPathList.Create(["/var/log", "/etc/nginx"]) };
|
||||
|
||||
HostKind.Instance.Changes(before, after).ShouldBe(["Pinned paths"]);
|
||||
HostKind.Instance.Changes(before, before with { PinnedPaths = PinnedPathList.Empty })
|
||||
.ShouldBe(["Pinned paths"]);
|
||||
HostKind.Instance.Changes(before, before with { }).ShouldBeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user