Public Access
Merge claude/spelunk-tunnel: reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
This commit is contained in:
commit
3d0a66f997
32 files changed
+3425
-84
No files matched your search
@@ -452,6 +452,36 @@ says so, and the terminal is unaffected. Any row can be switched off and on whil
|
|||||||
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
||||||
[The Android head](#the-android-head).
|
[The Android head](#the-android-head).
|
||||||
|
|
||||||
|
### Reaching a server through Spelunk
|
||||||
|
|
||||||
|
A server managed by Spelunk runs Spelunk's agent, which keeps a connection open to Spelunk. DodoSSH can use
|
||||||
|
that connection instead of dialling: tick **Connect through Spelunk** in the host editor and fill in
|
||||||
|
Spelunk's API address, the server's id in Spelunk, and 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.
|
||||||
|
|
||||||
|
**"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. The link never carries a credential. If a saved host
|
||||||
|
already reaches that server through that Spelunk, DodoSSH connects to it; otherwise it opens the host editor
|
||||||
|
filled in for a new Spelunk host — name, address, API and server — and you choose how to log in and which
|
||||||
|
credential holds the key, and save. Nothing is ever connected that you have not saved, and a link that
|
||||||
|
arrives while the vault is locked waits for the unlock.
|
||||||
|
|
||||||
|
The Windows installer registers the scheme for your user and removes it on uninstall, the macOS bundle
|
||||||
|
declares it in its `Info.plist`, and the Linux package's desktop entry declares `x-scheme-handler/dodossh`.
|
||||||
|
The phone reaches Spelunk hosts the same way but does not answer links yet.
|
||||||
|
|
||||||
### Sharing a vault
|
### Sharing a vault
|
||||||
|
|
||||||
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
|
||||||
|
|||||||
@@ -247,6 +247,13 @@ ln -s ../../opt/%{dodo_dir}/DodoSSH %{buildroot}%{_bindir}/%{dodo_bin}
|
|||||||
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
# window is a second, unlabelled icon in the dock beside the one that launched it. It is the assembly
|
||||||
# name deliberately and not the package's binary name — the window does not know which package
|
# name deliberately and not the package's binary name — the window does not know which package
|
||||||
# started it.
|
# started it.
|
||||||
|
#
|
||||||
|
# %%u — RPM's spelling of a literal %u, the field code for "one URL" — and the MimeType line make this
|
||||||
|
# the handler for dodossh:// links, which is how Spelunk's "Open in DodoSSH" reaches the application:
|
||||||
|
# the desktop starts the binary with the link as its one argument, and Program.Main reads and checks
|
||||||
|
# it, or passes it to the instance already running. update-desktop-database in the post-install
|
||||||
|
# scriptlet is what makes xdg-open see the handler. Both channels declare it, so whichever is
|
||||||
|
# installed answers, and with both installed the desktop's own default decides.
|
||||||
mkdir -p %{buildroot}%{_datadir}/applications
|
mkdir -p %{buildroot}%{_datadir}/applications
|
||||||
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
|
||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
@@ -255,13 +262,14 @@ Version=1.0
|
|||||||
Name=%{dodo_title}
|
Name=%{dodo_title}
|
||||||
GenericName=SSH Client
|
GenericName=SSH Client
|
||||||
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
Comment=SSH and SFTP with an end-to-end encrypted credential vault
|
||||||
Exec=%{dodo_bin}
|
Exec=%{dodo_bin} %%u
|
||||||
Icon=%{dodo_bin}
|
Icon=%{dodo_bin}
|
||||||
Terminal=false
|
Terminal=false
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
StartupWMClass=DodoSSH
|
StartupWMClass=DodoSSH
|
||||||
Categories=Network;RemoteAccess;System;
|
Categories=Network;RemoteAccess;System;
|
||||||
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
Keywords=SSH;SFTP;Terminal;Remote;Shell;
|
||||||
|
MimeType=x-scheme-handler/dodossh;
|
||||||
DESKTOP
|
DESKTOP
|
||||||
|
|
||||||
# One file per size into the hicolor theme, named for the package so the two channels show different
|
# One file per size into the hicolor theme, named for the package so the two channels show different
|
||||||
|
|||||||
@@ -119,6 +119,25 @@
|
|||||||
<key>LSUIElement</key>
|
<key>LSUIElement</key>
|
||||||
<false/>
|
<false/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
|
||||||
|
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
|
||||||
|
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
|
||||||
|
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
|
||||||
|
for a Velopack install is on first launch from /Applications.
|
||||||
|
-->
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>dev.dodotech.dodossh.link</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>dodossh</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
|
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>© DodoTech. MIT licensed.</string>
|
<string>© DodoTech. MIT licensed.</string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -1212,6 +1212,35 @@
|
|||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
|
Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
|
||||||
|
one unticks the other. The key is a keychain credential chosen here, never typed here. See
|
||||||
|
VaultViewModel.Spelunk.cs.
|
||||||
|
-->
|
||||||
|
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
|
||||||
|
<TextBlock Classes="mono" FontSize="11.5"
|
||||||
|
Text="Reach this host through Spelunk"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
<StackPanel Spacing="8" IsVisible="{Binding EditorThroughSpelunk}">
|
||||||
|
<TextBox Classes="field" Text="{Binding EditorSpelunkApi}"
|
||||||
|
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||||
|
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
|
||||||
|
PlaceholderText="Spelunk server id" />
|
||||||
|
<TextBlock Classes="detail" Text="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>
|
||||||
|
</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. Connects only while Spelunk and its agent are reachable." />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
This page covers the whole screen while it is open, which is why it needs a Status line of its
|
||||||
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
own rather than relying on the one the list behind it draws at the top of HOSTS (see this file's
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ namespace DodoSSH.Client.App;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class DodoSshApp : Application
|
internal sealed partial class DodoSshApp : Application
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
|
||||||
|
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
|
||||||
|
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
|
||||||
|
/// the UI thread only.
|
||||||
|
/// </remarks>
|
||||||
|
private static Action<SpelunkLink>? openLink;
|
||||||
|
|
||||||
|
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
|
||||||
|
private static SpelunkLink? waitingLink;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
@@ -34,12 +48,59 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
ListenForOpenedLinks();
|
||||||
ShowSomethingWhileComposing(desktop);
|
ShowSomethingWhileComposing(desktop);
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnFrameworkInitializationCompleted();
|
base.OnFrameworkInitializationCompleted();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Takes the links the platform hands a running application, which on macOS is every link.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
|
||||||
|
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
|
||||||
|
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
|
||||||
|
/// activation. The scheme is declared for it in build/macos's Info.plist template.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
|
||||||
|
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
|
||||||
|
/// desktops that deliver links as arguments instead.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void ListenForOpenedLinks()
|
||||||
|
{
|
||||||
|
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
activatable.Activated += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
|
||||||
|
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
|
||||||
|
{
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
|
||||||
|
private static void Receive(SpelunkLink link)
|
||||||
|
{
|
||||||
|
if (openLink is { } open)
|
||||||
|
{
|
||||||
|
open(link);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
waitingLink = link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts a window on screen first, and composes the application behind it.
|
/// Puts a window on screen first, and composes the application behind it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -206,9 +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
|
// The other half of Program's claim on this profile: a second launch was refused, and this is what
|
||||||
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
|
||||||
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
|
||||||
|
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
|
||||||
|
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
|
||||||
var others = SingleInstance.Listen(
|
var others = SingleInstance.Listen(
|
||||||
paths,
|
paths,
|
||||||
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
|
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
|
||||||
|
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
|
||||||
|
|
||||||
|
openLink = viewModel.OpenLink;
|
||||||
|
|
||||||
|
if ((waitingLink ?? Program.LaunchLink) is { } first)
|
||||||
|
{
|
||||||
|
waitingLink = null;
|
||||||
|
viewModel.OpenLink(first);
|
||||||
|
}
|
||||||
|
|
||||||
// Started rather than awaited: the framework's initialisation must not block on a schema
|
// Started rather than awaited: the framework's initialisation must not block on a schema
|
||||||
// migration. The view model shows its own progress and handles its own failures, which is why
|
// migration. The view model shows its own progress and handles its own failures, which is why
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
/// process asks the first to come forward and then leaves, which is what every desktop application does and
|
||||||
/// what makes the exclusion invisible rather than mysterious.
|
/// what makes the exclusion invisible rather than mysterious.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
|
||||||
|
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
|
||||||
|
/// second launch like any other — so the doorbell has a second message, which carries the link to the
|
||||||
|
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static class SingleInstance
|
internal static class SingleInstance
|
||||||
{
|
{
|
||||||
@@ -46,6 +52,30 @@ internal static class SingleInstance
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string ComeForward = "dodossh.come-forward";
|
private const string ComeForward = "dodossh.come-forward";
|
||||||
|
|
||||||
|
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
|
||||||
|
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
|
||||||
|
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
|
||||||
|
/// passes is ignored whole — the window does not even come forward for it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
|
||||||
|
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
|
||||||
|
/// than half-obeying it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private const string OpenLinkPrefix = "dodossh.open-link ";
|
||||||
|
|
||||||
|
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
|
||||||
|
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
|
||||||
|
/// process spends on it.
|
||||||
|
/// </remarks>
|
||||||
|
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
|
||||||
|
|
||||||
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
|
||||||
@@ -86,7 +116,11 @@ internal static class SingleInstance
|
|||||||
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
|
||||||
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
|
||||||
/// </param>
|
/// </param>
|
||||||
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
|
/// <param name="link">
|
||||||
|
/// The link this launch was started with, if any — passed to the running instance instead of a bare
|
||||||
|
/// request to come forward.
|
||||||
|
/// </param>
|
||||||
|
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
@@ -116,7 +150,7 @@ internal static class SingleInstance
|
|||||||
|
|
||||||
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
|
||||||
// round trip on a local pipe rather than the whole window below.
|
// round trip on a local pipe rather than the whole window below.
|
||||||
if (AskTheRunningOneToComeForward(paths))
|
if (AskTheRunningOneToComeForward(paths, link))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -142,25 +176,32 @@ internal static class SingleInstance
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="paths">The profile this instance claimed.</param>
|
/// <param name="paths">The profile this instance claimed.</param>
|
||||||
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
/// <param name="comeForward">What to do when another launch asks for the window.</param>
|
||||||
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
|
/// <param name="openLink">
|
||||||
|
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
|
||||||
|
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
|
||||||
|
/// </param>
|
||||||
|
internal static IAsyncDisposable Listen(
|
||||||
|
ClientPaths paths,
|
||||||
|
Action comeForward,
|
||||||
|
Action<SpelunkLink>? openLink = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentNullException.ThrowIfNull(comeForward);
|
ArgumentNullException.ThrowIfNull(comeForward);
|
||||||
|
|
||||||
var listener = new Doorbell(PipeNameFor(paths), comeForward);
|
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
|
||||||
|
|
||||||
listener.Start();
|
listener.Start();
|
||||||
|
|
||||||
return listener;
|
return listener;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
|
/// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
|
||||||
/// <returns>Whether anything was listening.</returns>
|
/// <returns>Whether anything was listening.</returns>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
|
||||||
/// and proves itself, and this is the part with a protocol in it.
|
/// and proves itself, and this is the part with a protocol in it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
|
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
@@ -175,7 +216,9 @@ internal static class SingleInstance
|
|||||||
|
|
||||||
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
|
||||||
|
|
||||||
writer.WriteLine(ComeForward);
|
// The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
|
||||||
|
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
|
||||||
|
writer.WriteLine(Message(link));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -188,6 +231,42 @@ internal static class SingleInstance
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
|
||||||
|
internal static string Message(SpelunkLink? link) =>
|
||||||
|
link is null ? ComeForward : OpenLinkPrefix + link;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
|
||||||
|
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
|
||||||
|
/// including a link that is almost right — the caller then does nothing at all.
|
||||||
|
/// </returns>
|
||||||
|
internal static bool TryRead(string? said, out SpelunkLink? link)
|
||||||
|
{
|
||||||
|
link = null;
|
||||||
|
|
||||||
|
if (said is null || said.Length > MaxMessageLength)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
|
||||||
|
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
|
||||||
|
{
|
||||||
|
link = parsed;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
|
||||||
@@ -278,14 +357,16 @@ internal static class SingleInstance
|
|||||||
{
|
{
|
||||||
private readonly string name;
|
private readonly string name;
|
||||||
private readonly Action comeForward;
|
private readonly Action comeForward;
|
||||||
|
private readonly Action<SpelunkLink>? openLink;
|
||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
private Task? loop;
|
private Task? loop;
|
||||||
|
|
||||||
internal Doorbell(string name, Action comeForward)
|
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
|
||||||
{
|
{
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.comeForward = comeForward;
|
this.comeForward = comeForward;
|
||||||
|
this.openLink = openLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void Start() => loop = RunAsync(lifetime.Token);
|
internal void Start() => loop = RunAsync(lifetime.Token);
|
||||||
@@ -324,11 +405,16 @@ internal static class SingleInstance
|
|||||||
// leaveOpen, because this server outlives the conversation. See the remark above.
|
// leaveOpen, because this server outlives the conversation. See the remark above.
|
||||||
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
|
||||||
{
|
{
|
||||||
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
|
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
|
if (TryRead(said, out var link))
|
||||||
{
|
{
|
||||||
comeForward();
|
comeForward();
|
||||||
|
|
||||||
|
if (link is not null)
|
||||||
|
{
|
||||||
|
openLink?.Invoke(link);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,6 +434,39 @@ internal static class SingleInstance
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
|
||||||
|
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
|
||||||
|
/// reader buffers underneath, and a real message is one short line.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task<string?> ReadBoundedLineAsync(
|
||||||
|
StreamReader reader,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var line = new StringBuilder();
|
||||||
|
var next = new char[1];
|
||||||
|
|
||||||
|
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
|
||||||
|
{
|
||||||
|
if (next[0] == '\n')
|
||||||
|
{
|
||||||
|
return line.ToString().TrimEnd('\r');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.Length == MaxMessageLength)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
line.Append(next[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
/// <summary>Ends one conversation without ending the doorbell.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
using Microsoft.Win32;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Registers this executable as the handler for <c>dodossh://</c> links, for this user, on Windows.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Under HKCU\Software\Classes, because the install is per user.</b> Velopack installs into the user's
|
||||||
|
/// own profile with no elevation, and a handler registered machine-wide would need the elevation the
|
||||||
|
/// installer deliberately never asks for. Windows merges this key into <c>HKEY_CLASSES_ROOT</c> for the
|
||||||
|
/// user, which is all a browser consults.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Written by Velopack's install and update hooks, removed by its uninstall hook</b> — see
|
||||||
|
/// <c>Program.Main</c>. An update rewrites it because the first update to a build that has this is how an
|
||||||
|
/// existing installation gets it at all, and because it costs nothing to say again.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Removed only when it is still ours.</b> The release and the nightly are separate installations that
|
||||||
|
/// both answer <c>dodossh://</c>, and the last one installed holds the key. Uninstalling the other must not
|
||||||
|
/// take the handler away from the one that is still there.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
internal static class WindowsUrlScheme
|
||||||
|
{
|
||||||
|
private const string KeyPath = @"Software\Classes\" + SpelunkLink.Scheme;
|
||||||
|
private const string CommandPath = @"shell\open\command";
|
||||||
|
|
||||||
|
/// <summary>Points <c>dodossh://</c> at <paramref name="executable"/>.</summary>
|
||||||
|
internal static void Register(string executable)
|
||||||
|
{
|
||||||
|
using var scheme = Registry.CurrentUser.CreateSubKey(KeyPath);
|
||||||
|
|
||||||
|
scheme.SetValue(string.Empty, "URL:DodoSSH");
|
||||||
|
|
||||||
|
// Empty, and its presence is the whole of it: this value is what makes the key a URL scheme rather
|
||||||
|
// than a file type.
|
||||||
|
scheme.SetValue("URL Protocol", string.Empty);
|
||||||
|
|
||||||
|
using (var icon = scheme.CreateSubKey("DefaultIcon"))
|
||||||
|
{
|
||||||
|
icon.SetValue(string.Empty, $"\"{executable}\",0");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var command = scheme.CreateSubKey(CommandPath);
|
||||||
|
|
||||||
|
command.SetValue(string.Empty, CommandFor(executable));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Takes the handler away, if it is still <paramref name="executable"/>'s.</summary>
|
||||||
|
internal static void Unregister(string executable)
|
||||||
|
{
|
||||||
|
string? registered;
|
||||||
|
|
||||||
|
using (var command = Registry.CurrentUser.OpenSubKey($@"{KeyPath}\{CommandPath}"))
|
||||||
|
{
|
||||||
|
registered = command?.GetValue(string.Empty) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsOurs(registered, executable))
|
||||||
|
{
|
||||||
|
Registry.CurrentUser.DeleteSubKeyTree(KeyPath, throwOnMissingSubKey: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The command Windows runs for a link: this executable, with the link as its one argument.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both halves quoted. The path has spaces in it on any profile whose user name does, and the link is
|
||||||
|
/// handed over whole as <c>%1</c> — which Windows quotes into one argument only because it is quoted
|
||||||
|
/// here. Program.Main then reads it back and checks it before anything acts on it.
|
||||||
|
/// </remarks>
|
||||||
|
internal static string CommandFor(string executable) => $"\"{executable}\" \"%1\"";
|
||||||
|
|
||||||
|
/// <summary>Whether a registered command is the one <see cref="Register"/> writes for this executable.</summary>
|
||||||
|
internal static bool IsOurs(string? registered, string executable) =>
|
||||||
|
string.Equals(registered, CommandFor(executable), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -8,6 +8,16 @@ namespace DodoSSH.Client.App;
|
|||||||
|
|
||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
|
||||||
|
/// ordinary launch.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
|
||||||
|
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
|
||||||
|
/// with no arguments to have.
|
||||||
|
/// </remarks>
|
||||||
|
internal static SpelunkLink? LaunchLink { get; private set; }
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entry point.
|
/// Entry point.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -29,11 +39,12 @@ internal static class Program
|
|||||||
// and a silent installer run that reached either would be a background process holding the cache
|
// and a silent installer run that reached either would be a background process holding the cache
|
||||||
// file open during the very file operations the installer is performing.
|
// file open during the very file operations the installer is performing.
|
||||||
//
|
//
|
||||||
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
|
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
|
||||||
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
|
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
|
||||||
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
|
// after an update — a schema migration — already runs on every ordinary launch from
|
||||||
// before unlock and touching no encrypted content.
|
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
|
||||||
VelopackApp.Build().Run();
|
// have touch only the registry; see RunVelopack.
|
||||||
|
RunVelopack();
|
||||||
|
|
||||||
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
KeepTheWebViewProfileOutOfTheInstallDirectory();
|
||||||
|
|
||||||
@@ -51,16 +62,100 @@ internal static class Program
|
|||||||
*/
|
*/
|
||||||
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
|
||||||
|
|
||||||
using var claim = SingleInstance.Claim(paths);
|
// Read before the claim, because a launch that carries a link and finds DodoSSH already running
|
||||||
|
// passes the link on rather than merely asking for the window.
|
||||||
|
var link = LinkFrom(args);
|
||||||
|
|
||||||
|
using var claim = SingleInstance.Claim(paths, link: link);
|
||||||
|
|
||||||
if (claim is null)
|
if (claim is null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchLink = link;
|
||||||
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
|
||||||
|
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
|
||||||
|
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
|
||||||
|
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
|
||||||
|
/// <see cref="WindowsUrlScheme"/>.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RunVelopack()
|
||||||
|
{
|
||||||
|
var velopack = VelopackApp.Build();
|
||||||
|
|
||||||
|
if (OperatingSystem.IsWindows())
|
||||||
|
{
|
||||||
|
velopack = velopack
|
||||||
|
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
|
||||||
|
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
|
||||||
|
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
|
||||||
|
}
|
||||||
|
|
||||||
|
velopack.Run();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
|
||||||
|
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
|
||||||
|
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
|
||||||
|
/// </remarks>
|
||||||
|
private static SpelunkLink? LinkFrom(string[] args) =>
|
||||||
|
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
|
||||||
|
&& SpelunkLink.TryParse(candidate, out var link)
|
||||||
|
? link
|
||||||
|
: null;
|
||||||
|
|
||||||
|
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
|
||||||
|
/// convenience could not be set up would be the wrong trade — the application works without it, and the
|
||||||
|
/// only thing lost is Spelunk's button opening it.
|
||||||
|
/// </remarks>
|
||||||
|
private static void RegisterUrlScheme()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WindowsUrlScheme.Register(executable);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
// See the remarks.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
|
||||||
|
private static void UnregisterUrlScheme()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
WindowsUrlScheme.Unregister(executable);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
|
||||||
|
{
|
||||||
|
// See the remarks on RegisterUrlScheme.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -692,6 +692,51 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
◆ THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
|
||||||
|
because a host has one route. A host ticked here is reached through the Spelunk agent already
|
||||||
|
on the server, so it needs no open SSH port at all. The fields appear only when the box is
|
||||||
|
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
|
||||||
|
and synced like every other secret. See VaultViewModel.Spelunk.cs.
|
||||||
|
-->
|
||||||
|
<Border CornerRadius="12" Background="{StaticResource Field}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" VerticalContentAlignment="Center">
|
||||||
|
<TextBlock Text="Connect through Spelunk" FontSize="13"
|
||||||
|
FontWeight="SemiBold" Foreground="{StaticResource Text}"
|
||||||
|
TextWrapping="Wrap" />
|
||||||
|
</CheckBox>
|
||||||
|
<StackPanel Spacing="6" IsVisible="{Binding EditorThroughSpelunk}">
|
||||||
|
<TextBox Text="{Binding EditorSpelunkApi}" Height="36"
|
||||||
|
PlaceholderText="Spelunk API address (https://api.…)" />
|
||||||
|
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
|
||||||
|
PlaceholderText="Spelunk server id" />
|
||||||
|
<!--
|
||||||
|
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>
|
||||||
|
</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. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
Withdrawing host key trust lives here, in the host's own settings, because a changed host
|
||||||
key is refused outright with no way to continue past it — so a legitimately rebuilt server
|
key is refused outright with no way to continue past it — so a legitimately rebuilt server
|
||||||
|
|||||||
@@ -297,6 +297,30 @@ public sealed record HostSecret : IVaultSecret
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool RelayEnabled { get; init; }
|
public bool RelayEnabled { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How this host is reached through Spelunk, or null for a host that is dialled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A host carrying one is never dialled directly and never falls back to it, for the reason the relay
|
||||||
|
/// does not: the user chose this route because the direct one does not exist or does not work, and a
|
||||||
|
/// connection that quietly went another way would be the box saying one thing and the connection doing
|
||||||
|
/// another. See <see cref="SpelunkRoute"/> for what the route is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b><see cref="Hostname"/> and <see cref="Port"/> still matter, and not for dialling.</b> The host key
|
||||||
|
/// is pinned under them, as it is for every host, so they have to name this machine stably — a host made
|
||||||
|
/// from a Spelunk link takes the server's address, or a name made from its id when Spelunk gave none.
|
||||||
|
/// The port is also what the agent is asked to connect to on the server's own loopback.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Mutually exclusive with <see cref="RelayEnabled"/>.</b> Two routes to one host is the same failure
|
||||||
|
/// as two bindings: the editor, the connect path and the user would each be free to guess which one is
|
||||||
|
/// meant. <see cref="TryValidate"/> refuses the pair.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkRoute? Spelunk { get; init; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the fields that must hold before this can be stored.
|
/// Checks the fields that must hold before this can be stored.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -332,7 +356,8 @@ public sealed record HostSecret : IVaultSecret
|
|||||||
|
|
||||||
return PinnedPathsAreStorable(out reason)
|
return PinnedPathsAreStorable(out reason)
|
||||||
&& PortForwardsAreStorable(out reason)
|
&& PortForwardsAreStorable(out reason)
|
||||||
&& ReferencesAreStorable(out reason);
|
&& ReferencesAreStorable(out reason)
|
||||||
|
&& RouteIsStorable(out reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
|
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
|
||||||
@@ -356,6 +381,24 @@ public sealed record HostSecret : IVaultSecret
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks the Spelunk route, if there is one, and that it is the only route.</summary>
|
||||||
|
private bool RouteIsStorable([NotNullWhen(false)] out string? reason)
|
||||||
|
{
|
||||||
|
if (Spelunk is null)
|
||||||
|
{
|
||||||
|
reason = null;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RelayEnabled)
|
||||||
|
{
|
||||||
|
reason = "A host is reached through the server relay or through Spelunk, not both.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Spelunk.TryValidate(out reason);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks the paths pinned on this host.
|
/// Checks the paths pinned on this host.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -119,12 +119,28 @@ public static class HostSecretCodec
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int PortForwardsSchemaVersion = 8;
|
public const int PortForwardsSchemaVersion = 8;
|
||||||
|
|
||||||
|
/// <summary>The version that introduced <see cref="HostSecret.Spelunk"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Its own version, by the rule this file follows, and it matters more here than for a tag. An older
|
||||||
|
/// build that decoded a Spelunk host as editable would drop the route on the next save — and the host
|
||||||
|
/// would then be dialled directly, at an address that exists only so its key has a name to be pinned
|
||||||
|
/// under. Written at this version, the older build shows the host, refuses to edit it, and says to update.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Additive, like every version but <see cref="PortInheritSchemaVersion"/>: the route is one more
|
||||||
|
/// property, and a build that does not know it skips it and still reads a valid host. It follows
|
||||||
|
/// <see cref="PortForwardsSchemaVersion"/>, whose forwards reached main first.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public const int SpelunkSchemaVersion = 9;
|
||||||
|
|
||||||
/// <summary>The highest schema version this build can write.</summary>
|
/// <summary>The highest schema version this build can write.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
|
||||||
/// maximum. A new field added below this line has to be named here too.
|
/// maximum. A new field added below this line has to be named here too.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public const int CurrentSchemaVersion = PortForwardsSchemaVersion;
|
public const int CurrentSchemaVersion = SpelunkSchemaVersion;
|
||||||
|
|
||||||
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
/// <summary>Serialises a host to the bytes that get sealed.</summary>
|
||||||
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
|
||||||
@@ -175,6 +191,16 @@ public static class HostSecretCodec
|
|||||||
PortForwards = host.PortForwards.Count == 0
|
PortForwards = host.PortForwards.Count == 0
|
||||||
? null
|
? null
|
||||||
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
: [.. host.PortForwards.Select(PortForwardDocument.From)],
|
||||||
|
// Last of all, and absent for a host that is dialled, so every host without a route encodes
|
||||||
|
// exactly as it did before routes existed.
|
||||||
|
Spelunk = host.Spelunk is { } route
|
||||||
|
? new SpelunkPayloadDocument
|
||||||
|
{
|
||||||
|
Api = route.ApiBase,
|
||||||
|
ServerId = route.ServerId,
|
||||||
|
ApiKeyCredentialId = route.ApiKeyCredentialId,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
return JsonSerializer.SerializeToUtf8Bytes(
|
return JsonSerializer.SerializeToUtf8Bytes(
|
||||||
@@ -255,6 +281,11 @@ public static class HostSecretCodec
|
|||||||
version = Math.Max(version, PortForwardsSchemaVersion);
|
version = Math.Max(version, PortForwardsSchemaVersion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (host.Spelunk is not null)
|
||||||
|
{
|
||||||
|
version = Math.Max(version, SpelunkSchemaVersion);
|
||||||
|
}
|
||||||
|
|
||||||
return version;
|
return version;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +371,7 @@ public static class HostSecretCodec
|
|||||||
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
|
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
|
||||||
|
|
||||||
PortForwards = forwards,
|
PortForwards = forwards,
|
||||||
|
Spelunk = RouteFrom(parsed.Spelunk),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!candidate.TryValidate(out _))
|
if (!candidate.TryValidate(out _))
|
||||||
@@ -389,6 +421,23 @@ public static class HostSecretCodec
|
|||||||
forwards = PortForwardList.Create(rules);
|
forwards = PortForwardList.Create(rules);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The route as stored, or null when there is none.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Missing parts come through as empty values rather than making the route vanish, so that
|
||||||
|
/// <see cref="HostSecret.TryValidate"/> refuses the host. Reading a half-written route as "no route"
|
||||||
|
/// would turn a Spelunk host into one that is dialled directly, which is the one reading of a damaged
|
||||||
|
/// payload that does something the user never asked for.
|
||||||
|
/// </remarks>
|
||||||
|
private static SpelunkRoute? RouteFrom(SpelunkPayloadDocument? parsed) =>
|
||||||
|
parsed is null
|
||||||
|
? null
|
||||||
|
: new SpelunkRoute
|
||||||
|
{
|
||||||
|
ApiBase = parsed.Api ?? string.Empty,
|
||||||
|
ServerId = parsed.ServerId ?? Guid.Empty,
|
||||||
|
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -462,6 +511,23 @@ internal sealed class HostPayloadDocument
|
|||||||
|
|
||||||
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
|
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
|
||||||
public PortForwardDocument?[]? PortForwards { get; set; }
|
public PortForwardDocument?[]? PortForwards { get; set; }
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Last, and null for a host that is dialled, for the reason <see cref="TagIds"/> gives. One nested object
|
||||||
|
/// rather than three loose properties, because the three are one decision — a server id without its
|
||||||
|
/// Spelunk means nothing — and an object makes "all or none" the shape of the JSON.
|
||||||
|
/// </remarks>
|
||||||
|
public SpelunkPayloadDocument? Spelunk { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The serialised shape of a <see cref="SpelunkRoute"/>. Nullable because it models untrusted input.</summary>
|
||||||
|
internal sealed class SpelunkPayloadDocument
|
||||||
|
{
|
||||||
|
public string? Api { get; set; }
|
||||||
|
|
||||||
|
public Guid? ServerId { get; set; }
|
||||||
|
|
||||||
|
public Guid? ApiKeyCredentialId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
|
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
|
||||||
|
|||||||
@@ -168,6 +168,18 @@ public static class HostSecretMerge
|
|||||||
remote.AsksForPassword,
|
remote.AsksForPassword,
|
||||||
conflicts,
|
conflicts,
|
||||||
static asked => asked is true ? "a typed password" : "the group's binding"),
|
static asked => asked is true ? "a typed password" : "the group's binding"),
|
||||||
|
|
||||||
|
// Here rather than beside RelayEnabled, because it is a reference too — to a Spelunk server and to
|
||||||
|
// the credential holding its key — and it merges as one value for the reason the jump chain does:
|
||||||
|
// an API address from one side and a server id from the other would be a route neither user
|
||||||
|
// chose, to a server that may not exist on that Spelunk.
|
||||||
|
Spelunk = Field(
|
||||||
|
nameof(HostSecret.Spelunk),
|
||||||
|
ancestor.Spelunk,
|
||||||
|
local.Spelunk,
|
||||||
|
remote.Spelunk,
|
||||||
|
conflicts,
|
||||||
|
static route => route is null ? "dialled directly" : $"server {route.ServerId} on {route.ApiBase}"),
|
||||||
};
|
};
|
||||||
|
|
||||||
private static string Text(
|
private static string Text(
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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 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
|
/// disposal is what closes the relay. <see cref="DisposeAsync"/> is for a caller that ends up not
|
||||||
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
/// connecting; after a hand-over it does nothing, because the bridge's disposal is idempotent.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class RelayRoute : IAsyncDisposable
|
public sealed class RelayRoute : IConnectionRoute
|
||||||
{
|
{
|
||||||
private readonly RelayBridge bridge;
|
private readonly RelayBridge bridge;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
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 select a host the user already saved, or open the editor on a new one
|
||||||
|
/// that the user then has to finish and save. It never connects to anything the user has not saved.
|
||||||
|
/// </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,555 @@
|
|||||||
|
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 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.</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,
|
||||||
|
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)
|
||||||
|
{
|
||||||
|
this.bridge = bridge;
|
||||||
|
this.port = port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public SshLoopbackProxy Proxy => bridge.Proxy;
|
||||||
|
|
||||||
|
/// <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) : 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) => code switch
|
||||||
|
{
|
||||||
|
SpelunkTunnelProtocol.TargetUnreachableCloseCode
|
||||||
|
when reason?.Contains(SpelunkTunnelProtocol.UnknownStreamKind, StringComparison.OrdinalIgnoreCase) is true =>
|
||||||
|
"The Spelunk agent on this server is too old to open a terminal. Update the agent, then try again.",
|
||||||
|
|
||||||
|
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 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,
|
||||||
|
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, 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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,
|
||||||
|
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.
|
||||||
|
request.Content = new ByteArrayContent(JsonSerializer.SerializeToUtf8Bytes(
|
||||||
|
new SpelunkTicketRequestDocument { Port = port },
|
||||||
|
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, 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,
|
||||||
|
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)))
|
||||||
|
{
|
||||||
|
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>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
|
||||||
|
{
|
||||||
|
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>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>
|
||||||
|
internal sealed class SpelunkTicketRequestDocument
|
||||||
|
{
|
||||||
|
public int Port { 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 two fields of an RFC 7807 problem this client repeats.</summary>
|
||||||
|
internal sealed class SpelunkProblemDocument
|
||||||
|
{
|
||||||
|
public string? Title { get; set; }
|
||||||
|
|
||||||
|
public string? Detail { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[JsonSourceGenerationOptions(
|
||||||
|
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
|
||||||
|
PropertyNameCaseInsensitive = true,
|
||||||
|
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 connects to a host the user has not
|
||||||
|
/// saved.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A locked application keeps it rather than dropping it.</b> Clicking a server in Spelunk and being
|
||||||
|
/// asked for the passphrase is the ordinary first half of this; losing the click at the second half would
|
||||||
|
/// make the button look broken. It is not kept across a restart.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal void OpenLink(SpelunkLink link)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(link);
|
||||||
|
|
||||||
|
pendingLink = link;
|
||||||
|
|
||||||
|
if (State is ShellState.Unlocked && Vault is not null)
|
||||||
|
{
|
||||||
|
DeliverPendingLink();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (State is ShellState.Locked)
|
||||||
|
{
|
||||||
|
StatusMessage = $"Unlock to open {link.Name} from Spelunk.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands a waiting link to the open vault, on the Hosts screen where its answer appears.</summary>
|
||||||
|
private void DeliverPendingLink()
|
||||||
|
{
|
||||||
|
if (pendingLink is not { } link || Vault is not { } vault)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingLink = null;
|
||||||
|
|
||||||
|
ShowScreen(ShellScreen.Hosts);
|
||||||
|
|
||||||
|
// Not awaited: a connect reports its own progress and failures on the vault's status line and in its
|
||||||
|
// tab, as a tap on the row does, and this is called from places that must not wait on a handshake.
|
||||||
|
_ = vault.OpenSpelunkLinkAsync(link);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -360,6 +360,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly ISshReachabilityProbe? reachability;
|
private readonly ISshReachabilityProbe? reachability;
|
||||||
|
|
||||||
|
/// <summary>What opens a Spelunk tunnel, handed to each vault this shell opens.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Held for the reason <see cref="reachability"/> is: the vault is built on unlock, not here. Null means
|
||||||
|
/// the real one, which is what both heads use; a test passes one so a Spelunk host's connect never leaves
|
||||||
|
/// the process.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly ISpelunkTunnel? spelunk;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A <c>dodossh://spelunk/connect</c> link that arrived while there was no open vault to act on it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// One, and the latest: a link is somebody pressing a button in Spelunk, and a second press while this
|
||||||
|
/// machine is still locked means the second server, not both. Delivered as soon as a vault opens, and
|
||||||
|
/// dropped if the application closes first — it is a request to do something now, not something to
|
||||||
|
/// remember.
|
||||||
|
/// </remarks>
|
||||||
|
private SpelunkLink? pendingLink;
|
||||||
|
|
||||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
||||||
private readonly Action<Action> post;
|
private readonly Action<Action> post;
|
||||||
|
|
||||||
@@ -569,9 +588,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Action<Action>? post = null,
|
Action<Action>? post = null,
|
||||||
Func<string, Task>? openInEditor = null,
|
Func<string, Task>? openInEditor = null,
|
||||||
Func<string, Action, IDisposable>? watchFile = null,
|
Func<string, Action, IDisposable>? watchFile = null,
|
||||||
ISshReachabilityProbe? reachability = null)
|
ISshReachabilityProbe? reachability = null,
|
||||||
|
ISpelunkTunnel? spelunk = null)
|
||||||
{
|
{
|
||||||
this.reachability = reachability;
|
this.reachability = reachability;
|
||||||
|
this.spelunk = spelunk;
|
||||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
||||||
|
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
@@ -2938,7 +2959,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
connectionLog,
|
connectionLog,
|
||||||
visibility,
|
visibility,
|
||||||
reachability);
|
reachability,
|
||||||
|
spelunk);
|
||||||
State = ShellState.Unlocked;
|
State = ShellState.Unlocked;
|
||||||
|
|
||||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||||
@@ -2954,19 +2976,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// After the load, because the switches are built from the vaults the session admitted and the
|
AfterTheFirstLoad(Vault);
|
||||||
// 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 first load, so the list is on screen before anything talks to a server. The loop is
|
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
||||||
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
||||||
@@ -2984,6 +2994,32 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Vault.StartAutoSync();
|
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>
|
/// <summary>
|
||||||
/// Points the two process-lifetime stores at the session that has just opened.
|
/// Points the two process-lifetime stores at the session that has just opened.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1816,7 +1816,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the session on a host, through the relay when the host uses it, or says why not.
|
/// Opens the session on a host, through the relay or Spelunk when the host uses one, or says why not.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
|
/// <returns>The session, or null with <see cref="Status"/> saying why there is none.</returns>
|
||||||
private async Task<ISftpSession?> OpenHostSessionAsync(
|
private async Task<ISftpSession?> OpenHostSessionAsync(
|
||||||
@@ -1825,25 +1825,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
RelayRoute? relay = null;
|
IConnectionRoute? route = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Through the relay for a host set to use it, exactly as a shell to it would be. The
|
// Through the relay or Spelunk for a host set to use one, exactly as a shell to it would be.
|
||||||
// session owns the relay from here, on success and on failure alike.
|
// The session owns the route from here, on success and on failure alike.
|
||||||
relay = row.Host.RelayEnabled
|
route = await open.OpenRouteAsync(row, cancellationToken).ConfigureAwait(true);
|
||||||
? await open.OpenRelayAsync(row.Label, row.EntityId, cancellationToken).ConfigureAwait(true)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return await sftp
|
return await sftp
|
||||||
.OpenSftpAsync(relay is null ? request : request with { Proxy = relay.Proxy }, cancellationToken)
|
.OpenSftpAsync(route is null ? request : request with { Proxy = route.Proxy }, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
when (relay?.Failure is { } failure
|
when (route?.Failure is { } failure
|
||||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||||
{
|
{
|
||||||
// See VaultViewModel.OpenThroughAsync: the relay's reason is the true one.
|
// See VaultViewModel.OpenThroughAsync: the route's reason is the true one.
|
||||||
Status = failure;
|
Status = failure;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using DodoSSH.Client.Domain;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
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>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]
|
||||||
|
private bool editorThroughSpelunk;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
|
||||||
|
/// <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>
|
||||||
|
/// </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>
|
||||||
|
/// 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>
|
||||||
|
/// </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, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Does what a <c>dodossh://spelunk/connect</c> link asks, as far as it is allowed to.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>
|
||||||
|
/// The connection attempt when a saved host was found, so a caller that wants to can wait for it; a
|
||||||
|
/// completed task otherwise.
|
||||||
|
/// </returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>A saved host for that server on that Spelunk is connected to</b>, exactly as a tap on its row
|
||||||
|
/// would — a password prompt if it asks for one, a host key question if it has not been seen.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Otherwise the editor opens on a new host, and nothing more.</b> The link came from outside, and
|
||||||
|
/// anything on this machine can make one; it must never be able to make this application connect
|
||||||
|
/// somewhere, or send an API key somewhere, that the user has not looked at and saved. So the host is
|
||||||
|
/// pre-filled — the name, the address its key will be pinned under, the Spelunk and the server — and
|
||||||
|
/// the user chooses how to log in and which credential holds the key, and presses SAVE.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The key is pre-chosen only when another host already sends it to the same Spelunk.</b> That is a
|
||||||
|
/// convenience that cannot leak anything — the key already goes there — whereas guessing a key for a
|
||||||
|
/// Spelunk no host uses would be offering to send it to whatever address the link named.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal Task OpenSpelunkLinkAsync(SpelunkLink link)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(link);
|
||||||
|
|
||||||
|
if (Hosts.FirstOrDefault(row => row.Host.Spelunk?.IsFor(link.ApiBase, link.ServerId) is true) is { } saved)
|
||||||
|
{
|
||||||
|
return ConnectToRowCommand.ExecuteAsync(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
NewHost();
|
||||||
|
|
||||||
|
EditorLabel = link.Name;
|
||||||
|
EditorHostname = link.Hostname;
|
||||||
|
EditorPort = HostSecret.DefaultPort;
|
||||||
|
EditorThroughSpelunk = true;
|
||||||
|
EditorSpelunkApi = link.ApiBase;
|
||||||
|
EditorSpelunkServerId = link.ServerId.ToString();
|
||||||
|
|
||||||
|
var knownKey = Hosts
|
||||||
|
.Select(row => row.Host.Spelunk)
|
||||||
|
.FirstOrDefault(route => route is not null && string.Equals(route.ApiBase, link.ApiBase, StringComparison.Ordinal))
|
||||||
|
?.ApiKeyCredentialId;
|
||||||
|
|
||||||
|
BuildSpelunkKeyChoices(Credentials.Any(credential => credential.EntityId == knownKey) ? knownKey : null);
|
||||||
|
|
||||||
|
IsHostPaneOpen = true;
|
||||||
|
Status = EditorSelectedSpelunkKey is null
|
||||||
|
? $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in and the credential "
|
||||||
|
+ "holding your Spelunk API key, then save."
|
||||||
|
: $"Adding {link.Name} from Spelunk at {link.ApiBase}. Choose how to log in, then save.";
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool Uses(HostRowViewModel row, ResolvedBindingKind kind, Guid entityId) =>
|
||||||
|
(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;
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1482,7 +1482,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
ConnectionRecorder? connectionLog = null,
|
ConnectionRecorder? connectionLog = null,
|
||||||
VaultVisibility? visibility = null,
|
VaultVisibility? visibility = null,
|
||||||
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
ISshReachabilityProbe? reachability = null,
|
||||||
|
ISpelunkTunnel? spelunk = null) : ObservableObject, IAsyncDisposable
|
||||||
{
|
{
|
||||||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -4588,6 +4589,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// longer say what the user chose.
|
// longer say what the user chose.
|
||||||
var authentication = EditorSelectedAuthentication;
|
var authentication = EditorSelectedAuthentication;
|
||||||
var groupId = EditorSelectedGroup?.EntityId;
|
var groupId = EditorSelectedGroup?.EntityId;
|
||||||
|
var spelunkKeyId = EditorSelectedSpelunkKey?.EntityId;
|
||||||
|
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
|
|
||||||
@@ -4606,6 +4608,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
|
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
|
||||||
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
|
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
|
||||||
grouped: EditorSelectedGroup?.EntityId is not null);
|
grouped: EditorSelectedGroup?.EntityId is not null);
|
||||||
|
|
||||||
|
BuildSpelunkKeyChoices(spelunkKeyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsEditingGroup)
|
if (IsEditingGroup)
|
||||||
@@ -7745,6 +7749,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorUsername = string.Empty;
|
EditorUsername = string.Empty;
|
||||||
EditorNotes = string.Empty;
|
EditorNotes = string.Empty;
|
||||||
EditorRelayEnabled = false;
|
EditorRelayEnabled = false;
|
||||||
|
LoadEditorSpelunk(route: null);
|
||||||
editorTagIds = TagSet.Empty;
|
editorTagIds = TagSet.Empty;
|
||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
@@ -7836,6 +7841,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorUsername = row.Host.Username ?? string.Empty;
|
EditorUsername = row.Host.Username ?? string.Empty;
|
||||||
EditorNotes = row.Host.Notes ?? string.Empty;
|
EditorNotes = row.Host.Notes ?? string.Empty;
|
||||||
EditorRelayEnabled = row.Host.RelayEnabled;
|
EditorRelayEnabled = row.Host.RelayEnabled;
|
||||||
|
LoadEditorSpelunk(row.Host.Spelunk);
|
||||||
editorTagIds = row.Host.TagIds;
|
editorTagIds = row.Host.TagIds;
|
||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
@@ -11047,9 +11053,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
|
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
|
||||||
{
|
{
|
||||||
var bound = Hosts
|
var bound = Hosts
|
||||||
.Where(row => row.Resolved.Binding is { } binding
|
.Where(row => Uses(row, kind, entityId))
|
||||||
&& binding.Kind == kind
|
|
||||||
&& binding.EntityId == entityId)
|
|
||||||
.Select(row => row.Label)
|
.Select(row => row.Label)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
@@ -11876,10 +11880,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
PhaseReporter reporter,
|
PhaseReporter reporter,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
|
// A relay or Spelunk refusal already says exactly what happened, and a routed host is reached by a
|
||||||
// probe does not take: it would dial the host directly — the route the user chose the relay because
|
// path the probe does not take: it would dial the host directly — the route the user chose the relay
|
||||||
// they cannot use — and report on that instead.
|
// or Spelunk because they cannot use, or for a Spelunk host an address that exists only to name its
|
||||||
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
|
// key — and report on that instead.
|
||||||
|
if (reporter.Furthest != SshConnectionPhase.Reaching
|
||||||
|
|| exception is ServerRelayException or SpelunkTunnelException
|
||||||
|
|| target.Routed)
|
||||||
{
|
{
|
||||||
return exception.Message;
|
return exception.Message;
|
||||||
}
|
}
|
||||||
@@ -12046,11 +12053,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// this machine without touching the host.
|
/// this machine without touching the host.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
|
/// Reached through <see cref="OpenRouteAsync"/>, which is what both transports call — file transfer
|
||||||
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
|
/// opens a connection of its own, and a relayed host needs the relay there too.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
private async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||||
{
|
{
|
||||||
@@ -12063,17 +12070,18 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
|
/// Opens the session, through the relay or Spelunk when the host uses one, and puts the route's reason on
|
||||||
|
/// a failure.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
/// A route that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
||||||
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
|
/// every lost transport — a sentence that reads exactly like the direct failure the route was chosen to
|
||||||
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
|
/// get around. When the route said why it closed, that sentence is the true one and replaces it. The
|
||||||
/// host key refusals are left alone: a key was presented, so the relay did its job.
|
/// host key refusals are left alone: a key was presented, so the route did its job.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
|
/// The route is disposed on every failure. The connection owns it once it is handed over and disposes
|
||||||
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
||||||
/// bridge listening until its own timeout; disposing twice is safe.
|
/// bridge listening until its own timeout; disposing twice is safe.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -12086,28 +12094,30 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
||||||
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
||||||
var relay = target.Relayed
|
var route = target.Row is { } row
|
||||||
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
|
? await OpenRouteAsync(row, cancellationToken).ConfigureAwait(true)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return await workspace
|
return await workspace
|
||||||
.OpenSessionAsync(
|
.OpenSessionAsync(
|
||||||
relay is null ? request : request with { Proxy = relay.Proxy },
|
route is null ? request : request with { Proxy = route.Proxy },
|
||||||
TerminalSize.Default,
|
TerminalSize.Default,
|
||||||
reporter,
|
reporter,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
catch (Exception exception) when (relay is not null)
|
catch (Exception exception) when (route is not null)
|
||||||
{
|
{
|
||||||
await relay.DisposeAsync().ConfigureAwait(true);
|
await route.DisposeAsync().ConfigureAwait(true);
|
||||||
|
|
||||||
if (relay.Failure is { } failure
|
if (route.Failure is { } failure
|
||||||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||||||
{
|
{
|
||||||
throw new ServerRelayException(failure, exception);
|
throw route is SpelunkTunnelRoute
|
||||||
|
? new SpelunkTunnelException(failure, exception)
|
||||||
|
: new ServerRelayException(failure, exception);
|
||||||
}
|
}
|
||||||
|
|
||||||
throw;
|
throw;
|
||||||
@@ -12334,13 +12344,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
internal Guid? HostId => Row?.EntityId;
|
internal Guid? HostId => Row?.EntityId;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether this is reached through the server relay rather than dialled.
|
/// Whether this is reached through the server relay or through Spelunk rather than dialled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
|
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
||||||
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
|
/// reached with a key the item names, and a machine typed into the manual box has neither.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool Relayed => Row?.Host.RelayEnabled is true;
|
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -12549,6 +12559,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||||||
RelayEnabled = EditorRelayEnabled,
|
RelayEnabled = EditorRelayEnabled,
|
||||||
|
|
||||||
|
// Null unless the box is ticked, whatever the fields below it still hold: unticking is how a host
|
||||||
|
// goes back to being dialled, and it must not keep a route the user just turned off.
|
||||||
|
Spelunk = BuildSpelunkRoute(),
|
||||||
|
|
||||||
// Only ever true or null, never false: the two would mean the same thing, and writing false would
|
// Only ever true or null, never false: the two would mean the same thing, and writing false would
|
||||||
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
|
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
|
||||||
//
|
//
|
||||||
@@ -13322,8 +13336,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
||||||
{
|
{
|
||||||
var names = Hosts
|
var names = Hosts
|
||||||
.Where(row => row.Resolved.Binding is { } binding
|
.Where(row => Uses(row, kind, entityId))
|
||||||
&& binding.Kind == kind && binding.EntityId == entityId)
|
|
||||||
.Select(row => row.Label)
|
.Select(row => row.Label)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
@@ -13518,8 +13531,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (kind is { } resolvedKind && value is not null)
|
if (kind is { } resolvedKind && value is not null)
|
||||||
{
|
{
|
||||||
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
|
foreach (var row in Hosts.Where(row => Uses(row, resolvedKind, value.EntityId)))
|
||||||
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
|
|
||||||
{
|
{
|
||||||
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,7 @@ internal sealed class HostKind : IItemKind<HostSecret>
|
|||||||
Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
|
Note(changed, "Jump chain", before.JumpHostIds, after.JumpHostIds);
|
||||||
Note(changed, "Options", before.Options, after.Options);
|
Note(changed, "Options", before.Options, after.Options);
|
||||||
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
|
Note(changed, "Relay", before.RelayEnabled, after.RelayEnabled);
|
||||||
|
Note(changed, "Spelunk", before.Spelunk, after.Spelunk);
|
||||||
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
|
Note(changed, "SSH key", before.SshKeyId, after.SshKeyId);
|
||||||
Note(changed, "Credential", before.CredentialId, after.CredentialId);
|
Note(changed, "Credential", before.CredentialId, after.CredentialId);
|
||||||
Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
|
Note(changed, "Password prompt", before.AsksForPassword, after.AsksForPassword);
|
||||||
|
|||||||
@@ -0,0 +1,389 @@
|
|||||||
|
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, and a link
|
||||||
|
/// never connects anything the user has not saved.
|
||||||
|
/// </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");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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.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();
|
||||||
|
|
||||||
|
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 ALinkForASecondServerOnAKnownSpelunk_PicksTheKeyThatSpelunkAlreadyGets()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
|
shell.OpenLink(Link(server: Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c")));
|
||||||
|
|
||||||
|
vault.IsEditing.ShouldBeTrue();
|
||||||
|
vault.EditorSelectedSpelunkKey.ShouldNotBeNull().Label.ShouldBe("spelunk key");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ALinkForASavedHost_ConnectsToIt()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectThroughSpelunkAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
await vault.OpenSpelunkLinkAsync(Link(host: "198.51.100.7"));
|
||||||
|
|
||||||
|
vault.IsEditing.ShouldBeFalse();
|
||||||
|
spelunkTunnels.Opened.ShouldHaveSingleItem();
|
||||||
|
|
||||||
|
// The saved host, as saved — not the address the link happened to carry.
|
||||||
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
|
request.Host.ShouldBe("203.0.113.10");
|
||||||
|
|
||||||
|
await request.Proxy.ShouldNotBeNull().Lifetime.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());
|
||||||
|
}
|
||||||
|
|
||||||
|
[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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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; } = [];
|
||||||
|
|
||||||
|
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,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Opened.Add((apiBase, serverId, apiKey, port));
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
// 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
|
// and do a DNS lookup and open a socket. See FakeReachabilityProbe for why that matters beyond
|
||||||
// the wall clock.
|
// the wall clock.
|
||||||
reachability: reachability);
|
reachability: reachability,
|
||||||
|
|
||||||
|
// Scripted for the same reason: a Spelunk host's connect never leaves the process. See
|
||||||
|
// ShellFlowTests.Spelunk.cs.
|
||||||
|
spelunk: spelunkTunnels);
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
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.App.Platform;
|
||||||
using DodoSSH.Client.Session;
|
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,16 @@ internal static class HostFactory
|
|||||||
BindPort = 1080,
|
BindPort = 1080,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>A credential holding a Spelunk API key, for the hosts reached through Spelunk.</summary>
|
||||||
|
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-cccc-7c3d-8e4f-5a6b7c8d9e0c");
|
||||||
|
|
||||||
|
/// <summary>A server's id in Spelunk.</summary>
|
||||||
|
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-dddd-7c3d-8e4f-5a6b7c8d9e0d");
|
||||||
|
|
||||||
|
/// <summary>A route to <see cref="SpelunkServer"/>, with <see cref="SpelunkKey"/>.</summary>
|
||||||
|
internal static SpelunkRoute ThroughSpelunk(string apiBase = "https://api.spelunk.example.com") =>
|
||||||
|
new() { ApiBase = apiBase, ServerId = SpelunkServer, ApiKeyCredentialId = SpelunkKey };
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
/// <paramref name="port"/> defaults to an explicit 22 rather than to null, so a test that says nothing
|
||||||
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
|
/// about ports gets the host shape that existed before inheritance did — which is what nearly every
|
||||||
@@ -59,7 +69,8 @@ internal static class HostFactory
|
|||||||
Guid? groupId = null,
|
Guid? groupId = null,
|
||||||
Guid[]? tags = null,
|
Guid[]? tags = null,
|
||||||
string[]? pinnedPaths = null,
|
string[]? pinnedPaths = null,
|
||||||
PortForwardRule[]? portForwards = null) =>
|
PortForwardRule[]? portForwards = null,
|
||||||
|
SpelunkRoute? spelunk = null) =>
|
||||||
new()
|
new()
|
||||||
{
|
{
|
||||||
Label = label,
|
Label = label,
|
||||||
@@ -79,6 +90,7 @@ internal static class HostFactory
|
|||||||
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
|
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
|
||||||
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
|
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
|
||||||
PortForwards = portForwards is null ? PortForwardList.Empty : PortForwardList.Create(portForwards),
|
PortForwards = portForwards is null ? PortForwardList.Empty : PortForwardList.Create(portForwards),
|
||||||
|
Spelunk = spelunk,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ public sealed class HostSecretCodecTests
|
|||||||
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
|
/// "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
|
/// 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
|
/// 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
|
/// pair of pinned paths, a pair of forwards and a Spelunk route — which are orthogonal to the binding and
|
||||||
/// this host reach the highest schema version a valid host can.
|
/// 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>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AFullHost_RoundTrips()
|
public void AFullHost_RoundTrips()
|
||||||
@@ -34,12 +36,12 @@ public sealed class HostSecretCodecTests
|
|||||||
notes: "primary replica",
|
notes: "primary replica",
|
||||||
jumps: [Bastion, Relay],
|
jumps: [Bastion, Relay],
|
||||||
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
|
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
|
||||||
relayEnabled: true,
|
|
||||||
credentialId: credentialId,
|
credentialId: credentialId,
|
||||||
groupId: Production,
|
groupId: Production,
|
||||||
tags: [Pci, EuWest],
|
tags: [Pci, EuWest],
|
||||||
pinnedPaths: ["/var/www/app", "/var/log"],
|
pinnedPaths: ["/var/www/app", "/var/log"],
|
||||||
portForwards: [Postgres, Socks]);
|
portForwards: [Postgres, Socks],
|
||||||
|
spelunk: ThroughSpelunk());
|
||||||
|
|
||||||
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
|
||||||
|
|
||||||
@@ -50,6 +52,21 @@ public sealed class HostSecretCodecTests
|
|||||||
document.IsReadOnly.ShouldBeFalse();
|
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 ----
|
// ---- The schema version is content-dependent ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
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.SchemaVersion.ShouldBe(HostSecretCodec.CurrentSchemaVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
[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 9 reads a version 10 host, shows it, and refuses to re-encode what it cannot represent.
|
||||||
|
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"},"somethingNewer":true}
|
||||||
|
""");
|
||||||
|
|
||||||
|
HostSecretCodec.TryDecode(payload, out var document).ShouldBeTrue();
|
||||||
|
|
||||||
|
document.ShouldNotBeNull().IsReadOnly.ShouldBeTrue();
|
||||||
|
document.Host.Spelunk.ShouldBe(ThroughSpelunk());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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,283 @@
|
|||||||
|
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, 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, 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, Token));
|
||||||
|
|
||||||
|
refusal.Message.ShouldContain($"could not be reached at http://127.0.0.1:{port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
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) =>
|
||||||
|
new SpelunkTunnel().OpenAsync(
|
||||||
|
apiBase,
|
||||||
|
Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b"),
|
||||||
|
"spelunk_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG",
|
||||||
|
port,
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user