From 6e39807cc044469599e9da5a74da193bedbe142d Mon Sep 17 00:00:00 2001 From: Jaap-Jan de Wit | DodoTech Date: Mon, 28 Sep 2026 21:25:04 +0200 Subject: [PATCH] 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.. 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 ", 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. --- README.md | 30 + build/linux/dodossh.spec | 10 +- build/macos/Info.plist.template | 19 + .../Views/HostsScreen.axaml | 29 + src/DodoSSH.Client.App/App.axaml.cs | 74 ++- .../Platform/SingleInstance.cs | 139 ++++- .../Platform/WindowsUrlScheme.cs | 82 +++ src/DodoSSH.Client.App/Program.cs | 107 +++- src/DodoSSH.Client.App/Views/HostDrawer.axaml | 45 ++ src/DodoSSH.Client.Domain/HostSecret.cs | 46 +- src/DodoSSH.Client.Domain/HostSecretCodec.cs | 69 ++- src/DodoSSH.Client.Domain/HostSecretMerge.cs | 12 + src/DodoSSH.Client.Domain/SpelunkRoute.cs | 157 +++++ src/DodoSSH.Client.Session/ConnectionRoute.cs | 37 ++ src/DodoSSH.Client.Session/ServerRelay.cs | 2 +- src/DodoSSH.Client.Session/SpelunkLink.cs | 207 +++++++ src/DodoSSH.Client.Session/SpelunkTunnel.cs | 555 ++++++++++++++++++ .../ViewModels/MainWindowViewModel.Spelunk.cs | 61 ++ .../ViewModels/MainWindowViewModel.cs | 66 ++- .../ViewModels/TransfersViewModel.cs | 18 +- .../ViewModels/VaultViewModel.Spelunk.cs | 266 +++++++++ .../ViewModels/VaultViewModel.cs | 76 ++- src/DodoSSH.Client.Sync/ItemKinds.cs | 1 + .../ShellFlowTests.Spelunk.cs | 389 ++++++++++++ .../ShellFlowTests.cs | 6 +- .../SingleInstanceTests.cs | 148 +++++ .../HostFactory.cs | 14 +- .../HostSecretCodecTests.cs | 27 +- .../SpelunkRouteTests.cs | 199 +++++++ .../FakeSpelunk.cs | 224 +++++++ .../SpelunkLinkTests.cs | 115 ++++ .../SpelunkTunnelTests.cs | 283 +++++++++ 32 files changed, 3428 insertions(+), 85 deletions(-) create mode 100644 src/DodoSSH.Client.App/Platform/WindowsUrlScheme.cs create mode 100644 src/DodoSSH.Client.Domain/SpelunkRoute.cs create mode 100644 src/DodoSSH.Client.Session/ConnectionRoute.cs create mode 100644 src/DodoSSH.Client.Session/SpelunkLink.cs create mode 100644 src/DodoSSH.Client.Session/SpelunkTunnel.cs create mode 100644 src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.Spelunk.cs create mode 100644 src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.Spelunk.cs create mode 100644 tests/DodoSSH.Client.App.Tests/ShellFlowTests.Spelunk.cs create mode 100644 tests/DodoSSH.Client.Domain.Tests/SpelunkRouteTests.cs create mode 100644 tests/DodoSSH.Client.Session.Tests/FakeSpelunk.cs create mode 100644 tests/DodoSSH.Client.Session.Tests/SpelunkLinkTests.cs create mode 100644 tests/DodoSSH.Client.Session.Tests/SpelunkTunnelTests.cs diff --git a/README.md b/README.md index 458c99b..6134837 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,36 @@ What is not here: transferring a directory, dragging between the panes, and rout bastion — the last needs jump hosts the connection layer has not got. All three are in [`docs/design-import-gaps.md`](docs/design-import-gaps.md). +### 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 **VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one, diff --git a/build/linux/dodossh.spec b/build/linux/dodossh.spec index 723c526..2a5becc 100644 --- a/build/linux/dodossh.spec +++ b/build/linux/dodossh.spec @@ -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 # name deliberately and not the package's binary name — the window does not know which package # 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 cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <LSUIElement + + CFBundleURLTypes + + + CFBundleURLName + dev.dodotech.dodossh.link + CFBundleURLSchemes + + dodossh + + + + NSHumanReadableCopyright © DodoTech. MIT licensed. diff --git a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml index 1b094c3..dbb962f 100644 --- a/src/DodoSSH.Client.Android/Views/HostsScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/HostsScreen.axaml @@ -1212,6 +1212,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +