Reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link

A server Spelunk manages runs Spelunk's agent, which keeps a connection open
to Spelunk. A host can now be marked "Connect through Spelunk": a connect
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. Shells and file transfers both go this way.

Client, in Client.Session:

- SpelunkTunnel implements Spelunk's contract: POST
  {api}/api/servers/{id}/tunnel-tickets with the API key as a bearer token
  and {"port":N}, then a WebSocket to the API's own origin at the path
  Spelunk returns, offering spelunk.tunnel.v1 and ticket.<ticket>. After the
  upgrade the relay's RelayBridge carries the bytes unchanged. Every refusal
  is a sentence: 401 key, 403 role (with Spelunk's detail), 404 server, 503
  agent not connected, 401/503 on the upgrade, and close codes 4502 (agent
  could not reach the port, or is too old: "Unknown stream kind") and 4503
  (agent disconnected). Only a path is taken from the ticket response, so it
  cannot send the ticket to another host.
- IConnectionRoute is what RelayRoute and SpelunkTunnelRoute have in common,
  so the connect path has one shape for both.

Host model: HostSecret.Spelunk is a SpelunkRoute (normalised API base,
server id, and the id of the credential holding the API key), at payload
schema version 8. It is written last and only when present, so every other
host keeps its bytes and its version; an older build reads a Spelunk host
as read-only rather than dropping the route. A half-written route makes the
item unreadable instead of dialled. It merges as one value and is exclusive
with RelayEnabled. The API address must be https, or http to loopback.

The API key is an ordinary keychain credential whose password is the key,
referenced by id, so it is encrypted, synced, merged and rotated like every
other secret, one credential serves every server in the organisation, and
the used-by list and deletion warning count it.

Routing: OpenRouteAsync is the one decision for both transports. A Spelunk
host is never dialled directly, never probed after a failure, and a route
that hung up with a reason replaces SSH.NET's transport error.

UI: a "Connect through Spelunk" card beside the relay card in the desktop
drawer and the phone's editor; ticking one unticks the other.

dodossh://spelunk/connect?api=&server=&name=&host= links:

- SpelunkLink parses them strictly and never reads a credential from one.
- Program reads a link from the arguments; a launch that finds DodoSSH
  running passes it down the doorbell as "dodossh.open-link <link>", which
  the running instance parses again, bounded, and otherwise ignores whole.
  The plain come-forward ring is unchanged.
- A saved host routed to that server on that Spelunk is connected to;
  otherwise the editor opens on a new, unsaved Spelunk host, with the key
  pre-chosen only when another host already sends it to that Spelunk. A link
  that arrives while locked waits for the unlock.
- Windows registers the scheme under HKCU from Velopack's install and update
  hooks and removes it on uninstall only if it still points at this
  executable; the macOS plist template declares CFBundleURLTypes and the app
  takes the URL from Avalonia's activation event; the Linux desktop entry
  gets %u and MimeType=x-scheme-handler/dodossh.
This commit is contained in:
jaap-jan committed 2026-09-28 21:25:04 +02:00
1 parent c8848ec1e3
commit 6e39807cc0
32 files changed
+3428 -85

No files matched your search

+73 -1
View File
@@ -26,6 +26,20 @@ namespace DodoSSH.Client.App;
/// </remarks>
internal sealed partial class DodoSshApp : Application
{
/// <summary>
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
/// </summary>
/// <remarks>
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
/// the UI thread only.
/// </remarks>
private static Action<SpelunkLink>? openLink;
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
private static SpelunkLink? waitingLink;
/// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -34,12 +48,59 @@ internal sealed partial class DodoSshApp : Application
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
ListenForOpenedLinks();
ShowSomethingWhileComposing(desktop);
}
base.OnFrameworkInitializationCompleted();
}
/// <summary>
/// Takes the links the platform hands a running application, which on macOS is every link.
/// </summary>
/// <remarks>
/// <para>
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
/// activation. The scheme is declared for it in build/macos's Info.plist template.
/// </para>
/// <para>
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
/// desktops that deliver links as arguments instead.
/// </para>
/// </remarks>
private void ListenForOpenedLinks()
{
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
{
return;
}
activatable.Activated += (_, e) =>
{
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
}
};
}
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
private static void Receive(SpelunkLink link)
{
if (openLink is { } open)
{
open(link);
}
else
{
waitingLink = link;
}
}
/// <summary>
/// Puts a window on screen first, and composes the application behind it.
/// </summary>
@@ -206,9 +267,20 @@ internal sealed partial class DodoSshApp : Application
// The other half of Program's claim on this profile: a second launch was refused, and this is what
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
// a window may only be touched from the thread that owns it — see SingleInstance.Listen.
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
var others = SingleInstance.Listen(
paths,
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)));
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
openLink = viewModel.OpenLink;
if ((waitingLink ?? Program.LaunchLink) is { } first)
{
waitingLink = null;
viewModel.OpenLink(first);
}
// Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why
+129 -10
View File
@@ -34,6 +34,12 @@ namespace DodoSSH.Client.App.Platform;
/// process asks the first to come forward and then leaves, which is what every desktop application does and
/// what makes the exclusion invisible rather than mysterious.
/// </para>
/// <para>
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
/// second launch like any other — so the doorbell has a second message, which carries the link to the
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
/// </para>
/// </remarks>
internal static class SingleInstance
{
@@ -46,6 +52,30 @@ internal static class SingleInstance
/// </remarks>
private const string ComeForward = "dodossh.come-forward";
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
/// <remarks>
/// <para>
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
/// passes is ignored whole — the window does not even come forward for it.
/// </para>
/// <para>
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
/// than half-obeying it.
/// </para>
/// </remarks>
private const string OpenLinkPrefix = "dodossh.open-link ";
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
/// <remarks>
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
/// process spends on it.
/// </remarks>
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
/// <summary>How long a second launch waits for the first to answer its pipe.</summary>
/// <remarks>
/// Short, because the pipe is local and the process on the other end is either listening or not. It is
@@ -86,7 +116,11 @@ internal static class SingleInstance
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
/// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null)
/// <param name="link">
/// The link this launch was started with, if any — passed to the running instance instead of a bare
/// request to come forward.
/// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
{
ArgumentNullException.ThrowIfNull(paths);
@@ -116,7 +150,7 @@ internal static class SingleInstance
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one
// round trip on a local pipe rather than the whole window below.
if (AskTheRunningOneToComeForward(paths))
if (AskTheRunningOneToComeForward(paths, link))
{
return null;
}
@@ -142,25 +176,32 @@ internal static class SingleInstance
/// </remarks>
/// <param name="paths">The profile this instance claimed.</param>
/// <param name="comeForward">What to do when another launch asks for the window.</param>
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward)
/// <param name="openLink">
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
/// </param>
internal static IAsyncDisposable Listen(
ClientPaths paths,
Action comeForward,
Action<SpelunkLink>? openLink = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(comeForward);
var listener = new Doorbell(PipeNameFor(paths), comeForward);
var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
listener.Start();
return listener;
}
/// <summary>Tells the instance that holds this profile to raise its window.</summary>
/// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
/// <returns>Whether anything was listening.</returns>
/// <remarks>
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
/// and proves itself, and this is the part with a protocol in it.
/// </remarks>
internal static bool AskTheRunningOneToComeForward(ClientPaths paths)
internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
{
ArgumentNullException.ThrowIfNull(paths);
@@ -175,7 +216,9 @@ internal static class SingleInstance
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
writer.WriteLine(ComeForward);
// The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
writer.WriteLine(Message(link));
return true;
}
@@ -188,6 +231,42 @@ internal static class SingleInstance
}
}
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
internal static string Message(SpelunkLink? link) =>
link is null ? ComeForward : OpenLinkPrefix + link;
/// <summary>
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
/// </summary>
/// <returns>
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
/// including a link that is almost right — the caller then does nothing at all.
/// </returns>
internal static bool TryRead(string? said, out SpelunkLink? link)
{
link = null;
if (said is null || said.Length > MaxMessageLength)
{
return false;
}
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
{
return true;
}
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
{
link = parsed;
return true;
}
return false;
}
/// <summary>Takes the lock, or answers null if somebody else has it.</summary>
/// <remarks>
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
@@ -278,14 +357,16 @@ internal static class SingleInstance
{
private readonly string name;
private readonly Action comeForward;
private readonly Action<SpelunkLink>? openLink;
private readonly CancellationTokenSource lifetime = new();
private Task? loop;
internal Doorbell(string name, Action comeForward)
internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
{
this.name = name;
this.comeForward = comeForward;
this.openLink = openLink;
}
internal void Start() => loop = RunAsync(lifetime.Token);
@@ -324,11 +405,16 @@ internal static class SingleInstance
// leaveOpen, because this server outlives the conversation. See the remark above.
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
{
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false);
var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
if (TryRead(said, out var link))
{
comeForward();
if (link is not null)
{
openLink?.Invoke(link);
}
}
}
@@ -348,6 +434,39 @@ internal static class SingleInstance
}
}
/// <summary>
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
/// </summary>
/// <remarks>
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
/// reader buffers underneath, and a real message is one short line.
/// </remarks>
private static async Task<string?> ReadBoundedLineAsync(
StreamReader reader,
CancellationToken cancellationToken)
{
var line = new StringBuilder();
var next = new char[1];
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
{
if (next[0] == '\n')
{
return line.ToString().TrimEnd('\r');
}
if (line.Length == MaxMessageLength)
{
return null;
}
line.Append(next[0]);
}
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
}
/// <summary>Ends one conversation without ending the doorbell.</summary>
/// <remarks>
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
@@ -0,0 +1,82 @@
using System.Runtime.Versioning;
using DodoSSH.Client.Session;
using Microsoft.Win32;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Registers this executable as the handler for <c>dodossh://</c> links, for this user, on Windows.
/// </summary>
/// <remarks>
/// <para>
/// <b>Under HKCU\Software\Classes, because the install is per user.</b> Velopack installs into the user's
/// own profile with no elevation, and a handler registered machine-wide would need the elevation the
/// installer deliberately never asks for. Windows merges this key into <c>HKEY_CLASSES_ROOT</c> for the
/// user, which is all a browser consults.
/// </para>
/// <para>
/// <b>Written by Velopack's install and update hooks, removed by its uninstall hook</b> — see
/// <c>Program.Main</c>. An update rewrites it because the first update to a build that has this is how an
/// existing installation gets it at all, and because it costs nothing to say again.
/// </para>
/// <para>
/// <b>Removed only when it is still ours.</b> The release and the nightly are separate installations that
/// both answer <c>dodossh://</c>, and the last one installed holds the key. Uninstalling the other must not
/// take the handler away from the one that is still there.
/// </para>
/// </remarks>
[SupportedOSPlatform("windows")]
internal static class WindowsUrlScheme
{
private const string KeyPath = @"Software\Classes\" + SpelunkLink.Scheme;
private const string CommandPath = @"shell\open\command";
/// <summary>Points <c>dodossh://</c> at <paramref name="executable"/>.</summary>
internal static void Register(string executable)
{
using var scheme = Registry.CurrentUser.CreateSubKey(KeyPath);
scheme.SetValue(string.Empty, "URL:DodoSSH");
// Empty, and its presence is the whole of it: this value is what makes the key a URL scheme rather
// than a file type.
scheme.SetValue("URL Protocol", string.Empty);
using (var icon = scheme.CreateSubKey("DefaultIcon"))
{
icon.SetValue(string.Empty, $"\"{executable}\",0");
}
using var command = scheme.CreateSubKey(CommandPath);
command.SetValue(string.Empty, CommandFor(executable));
}
/// <summary>Takes the handler away, if it is still <paramref name="executable"/>'s.</summary>
internal static void Unregister(string executable)
{
string? registered;
using (var command = Registry.CurrentUser.OpenSubKey($@"{KeyPath}\{CommandPath}"))
{
registered = command?.GetValue(string.Empty) as string;
}
if (IsOurs(registered, executable))
{
Registry.CurrentUser.DeleteSubKeyTree(KeyPath, throwOnMissingSubKey: false);
}
}
/// <summary>The command Windows runs for a link: this executable, with the link as its one argument.</summary>
/// <remarks>
/// Both halves quoted. The path has spaces in it on any profile whose user name does, and the link is
/// handed over whole as <c>%1</c> — which Windows quotes into one argument only because it is quoted
/// here. Program.Main then reads it back and checks it before anything acts on it.
/// </remarks>
internal static string CommandFor(string executable) => $"\"{executable}\" \"%1\"";
/// <summary>Whether a registered command is the one <see cref="Register"/> writes for this executable.</summary>
internal static bool IsOurs(string? registered, string executable) =>
string.Equals(registered, CommandFor(executable), StringComparison.OrdinalIgnoreCase);
}
+101 -6
View File
@@ -8,6 +8,16 @@ namespace DodoSSH.Client.App;
internal static class Program
{
/// <summary>
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
/// ordinary launch.
/// </summary>
/// <remarks>
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
/// with no arguments to have.
/// </remarks>
internal static SpelunkLink? LaunchLink { get; private set; }
/// <summary>
/// Entry point.
/// </summary>
@@ -29,11 +39,12 @@ internal static class Program
// and a silent installer run that reached either would be a background process holding the cache
// file open during the very file operations the installer is performing.
//
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase,
// so the cache is bytes it cannot read, and the one thing that would want doing after an update —
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync,
// before unlock and touching no encrypted content.
VelopackApp.Build().Run();
// There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
// has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
// after an update — a schema migration — already runs on every ordinary launch from
// MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
// have touch only the registry; see RunVelopack.
RunVelopack();
KeepTheWebViewProfileOutOfTheInstallDirectory();
@@ -51,16 +62,100 @@ internal static class Program
*/
var paths = ClientPaths.ForChannel(DesktopChannel.Name);
using var claim = SingleInstance.Claim(paths);
// Read before the claim, because a launch that carries a link and finds DodoSSH already running
// passes the link on rather than merely asking for the window.
var link = LinkFrom(args);
using var claim = SingleInstance.Claim(paths, link: link);
if (claim is null)
{
return;
}
LaunchLink = link;
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
/// <remarks>
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
/// <see cref="WindowsUrlScheme"/>.
/// </remarks>
private static void RunVelopack()
{
var velopack = VelopackApp.Build();
if (OperatingSystem.IsWindows())
{
velopack = velopack
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
}
velopack.Run();
}
/// <summary>
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
/// </summary>
/// <remarks>
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
/// </remarks>
private static SpelunkLink? LinkFrom(string[] args) =>
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
&& SpelunkLink.TryParse(candidate, out var link)
? link
: null;
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
/// <remarks>
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
/// convenience could not be set up would be the wrong trade — the application works without it, and the
/// only thing lost is Spelunk's button opening it.
/// </remarks>
private static void RegisterUrlScheme()
{
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
{
return;
}
try
{
WindowsUrlScheme.Register(executable);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// See the remarks.
}
}
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
private static void UnregisterUrlScheme()
{
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
{
return;
}
try
{
WindowsUrlScheme.Unregister(executable);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// See the remarks on RegisterUrlScheme.
}
}
/// <summary>
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
/// </summary>
@@ -664,6 +664,51 @@
</StackPanel>
</Border>
<!--
◆ THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
because a host has one route. A host ticked here is reached through the Spelunk agent already
on the server, so it needs no open SSH port at all. The fields appear only when the box is
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
and synced like every other secret. See VaultViewModel.Spelunk.cs.
-->
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
<StackPanel Spacing="6">
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" VerticalContentAlignment="Center">
<TextBlock Text="Connect through Spelunk" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
</CheckBox>
<StackPanel Spacing="6" IsVisible="{Binding EditorThroughSpelunk}">
<TextBox Text="{Binding EditorSpelunkApi}" Height="36"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
PlaceholderText="Spelunk server id" />
<!--
Credentials only: the key is the credential's password. A deleted one keeps a
placeholder, as the authentication picker's does, so an unrelated edit cannot drop it.
-->
<TextBlock Classes="hint" FontSize="10.5" Text="Credential holding the Spelunk API key" />
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
SelectedItem="{Binding EditorSelectedSpelunkKey}"
HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<TextBlock Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</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
key is refused outright with no way to continue past it — so a legitimately rebuilt server