Public Access
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:
1 parent
c8848ec1e3
commit
6e39807cc0
32 files changed
+3428
-85
No files matched your search
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user