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

+45 -1
View File
@@ -281,6 +281,30 @@ public sealed record HostSecret : IVaultSecret
/// </remarks>
public bool RelayEnabled { get; init; }
/// <summary>
/// How this host is reached through Spelunk, or null for a host that is dialled.
/// </summary>
/// <remarks>
/// <para>
/// A host carrying one is never dialled directly and never falls back to it, for the reason the relay
/// does not: the user chose this route because the direct one does not exist or does not work, and a
/// connection that quietly went another way would be the box saying one thing and the connection doing
/// another. See <see cref="SpelunkRoute"/> for what the route is.
/// </para>
/// <para>
/// <b><see cref="Hostname"/> and <see cref="Port"/> still matter, and not for dialling.</b> The host key
/// is pinned under them, as it is for every host, so they have to name this machine stably — a host made
/// from a Spelunk link takes the server's address, or a name made from its id when Spelunk gave none.
/// The port is also what the agent is asked to connect to on the server's own loopback.
/// </para>
/// <para>
/// <b>Mutually exclusive with <see cref="RelayEnabled"/>.</b> Two routes to one host is the same failure
/// as two bindings: the editor, the connect path and the user would each be free to guess which one is
/// meant. <see cref="TryValidate"/> refuses the pair.
/// </para>
/// </remarks>
public SpelunkRoute? Spelunk { get; init; }
/// <summary>
/// Checks the fields that must hold before this can be stored.
/// </summary>
@@ -314,7 +338,27 @@ public sealed record HostSecret : IVaultSecret
return false;
}
return PinnedPathsAreStorable(out reason) && ReferencesAreStorable(out reason);
return PinnedPathsAreStorable(out reason)
&& ReferencesAreStorable(out reason)
&& RouteIsStorable(out reason);
}
/// <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>
+68 -1
View File
@@ -112,12 +112,27 @@ public static class HostSecretCodec
/// </remarks>
public const int PinnedPathsSchemaVersion = 7;
/// <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.
/// </para>
/// </remarks>
public const int SpelunkSchemaVersion = 8;
/// <summary>The highest schema version this build can write.</summary>
/// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
/// maximum. A new field added below this line has to be named here too.
/// </remarks>
public const int CurrentSchemaVersion = PinnedPathsSchemaVersion;
public const int CurrentSchemaVersion = SpelunkSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -163,6 +178,17 @@ public static class HostSecretCodec
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
// as it did before this field existed.
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
// 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(
@@ -238,6 +264,11 @@ public static class HostSecretCodec
version = Math.Max(version, PinnedPathsSchemaVersion);
}
if (host.Spelunk is not null)
{
version = Math.Max(version, SpelunkSchemaVersion);
}
return version;
}
@@ -316,6 +347,8 @@ public static class HostSecretCodec
TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
Spelunk = RouteFrom(parsed.Spelunk),
};
if (!candidate.TryValidate(out _))
@@ -326,6 +359,23 @@ public static class HostSecretCodec
host = candidate;
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>
@@ -396,6 +446,23 @@ internal sealed class HostPayloadDocument
/// newer of the two and follows the same rule.
/// </remarks>
public string[]? PinnedPaths { 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; }
}
[JsonSourceGenerationOptions(
@@ -166,6 +166,18 @@ public static class HostSecretMerge
remote.AsksForPassword,
conflicts,
static asked => asked is true ? "a typed password" : "the group's binding"),
// Here rather than beside RelayEnabled, because it is a reference too — to a Spelunk server and to
// the credential holding its key — and it merges as one value for the reason the jump chain does:
// an API address from one side and a server id from the other would be a route neither user
// chose, to a server that may not exist on that Spelunk.
Spelunk = Field(
nameof(HostSecret.Spelunk),
ancestor.Spelunk,
local.Spelunk,
remote.Spelunk,
conflicts,
static route => route is null ? "dialled directly" : $"server {route.ServerId} on {route.ApiBase}"),
};
private static string Text(
+157
View File
@@ -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;
}
}