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

@@ -21,6 +21,16 @@ internal static class HostFactory
/// <summary>A second tag id, for the tests about two people tagging one host.</summary>
internal static Guid EuWest { get; } = Guid.Parse("0192f0c8-9999-7c3d-8e4f-5a6b7c8d9e09");
/// <summary>A credential holding a Spelunk API key, for the hosts reached through Spelunk.</summary>
internal static Guid SpelunkKey { get; } = Guid.Parse("0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a");
/// <summary>A server's id in Spelunk.</summary>
internal static Guid SpelunkServer { get; } = Guid.Parse("0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b");
/// <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>
/// <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
@@ -40,7 +50,8 @@ internal static class HostFactory
bool? asksForPassword = null,
Guid? groupId = null,
Guid[]? tags = null,
string[]? pinnedPaths = null) =>
string[]? pinnedPaths = null,
SpelunkRoute? spelunk = null) =>
new()
{
Label = label,
@@ -59,6 +70,7 @@ internal static class HostFactory
GroupId = groupId,
TagIds = tags is null ? TagSet.Empty : TagSet.Create(tags),
PinnedPaths = pinnedPaths is null ? PinnedPathList.Empty : PinnedPathList.Create(pinnedPaths),
Spelunk = spelunk,
};
/// <summary>
@@ -17,9 +17,11 @@ public sealed class HostSecretCodecTests
/// <remarks>
/// "Full" cannot mean every field: the two authentication bindings are mutually exclusive, so a host may
/// carry a key or a credential and never both, and neither may sit beside <c>AsksForPassword</c>. This
/// one carries the credential, because that is the newer of the two, plus a group, a pair of tags and a
/// pair of pinned paths — which are orthogonal to the binding and are what make this host reach the
/// highest schema version a valid host can.
/// 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 Spelunk route — which are orthogonal to the binding and are what make this
/// host reach the highest schema version a valid host can. The route and the relay are exclusive in the
/// same way the bindings are, so this carries the newer of those two as well; the relay's flag has its
/// own tests.
/// </remarks>
[Fact]
public void AFullHost_RoundTrips()
@@ -34,11 +36,11 @@ public sealed class HostSecretCodecTests
notes: "primary replica",
jumps: [Bastion, Relay],
options: [("ServerAliveInterval", "30"), ("Compression", "yes")],
relayEnabled: true,
credentialId: credentialId,
groupId: Production,
tags: [Pci, EuWest],
pinnedPaths: ["/var/www/app", "/var/log"]);
pinnedPaths: ["/var/www/app", "/var/log"],
spelunk: ThroughSpelunk());
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
@@ -49,6 +51,21 @@ public sealed class HostSecretCodecTests
document.IsReadOnly.ShouldBeFalse();
}
[Fact]
public void ARelayedHost_RoundTrips_AtTheVersionItsOtherFieldsNeed()
{
// The relay flag predates schema versions, so it costs none; it is here because the full host above
// now takes the Spelunk route, which the relay excludes.
var host = Host(relayEnabled: true);
HostSecretCodec.TryDecode(HostSecretCodec.Encode(host), out var document).ShouldBeTrue();
document.ShouldNotBeNull();
document.Host.ShouldBe(host);
document.Host.RelayEnabled.ShouldBeTrue();
document.SchemaVersion.ShouldBe(HostSecretCodec.BaseSchemaVersion);
}
// ---- The schema version is content-dependent ----
[Fact]
@@ -0,0 +1,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":8,"label":"prod-db","hostname":"db.internal","port":22,"jumpHostIds":[],"options":{},"relayEnabled":false,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"}}
""");
}
[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":8,"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 8 reads a version 9 host, shows it, and refuses to re-encode what it cannot represent.
var payload = Encoding.UTF8.GetBytes(
"""
{"schemaVersion":9,"label":"prod-db","hostname":"db.internal","port":22,"spelunk":{"api":"https://api.spelunk.example.com","serverId":"0192f0c8-bbbb-7c3d-8e4f-5a6b7c8d9e0b","apiKeyCredentialId":"0192f0c8-aaaa-7c3d-8e4f-5a6b7c8d9e0a"},"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));
}
}