Public Access
b08f2c58e3e30274baa696454b276c402ca257e6
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
6e39807cc0 |
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.
|
||
|
|
dabc691c35 |
Keep the doorbell listening between callers, and stop a hung test taking CI with it
The test step ran for twenty minutes and had to be stopped by hand. Eighteen suites
had passed; DodoSSH.Client.App.Tests never reported.
◆ ON UNIX, A FRESH SERVER PER CONNECTION LOSES THE NEXT ONE.
The doorbell's accept loop built a NamedPipeServerStream inside the loop, served one
caller and disposed it. That is fine on Windows and quietly wrong on Linux, where a
named pipe is a Unix domain socket: disposing the server closes the *listening*
socket and unlinks its path, so a second launch that connected while the first was
being served has its connection dropped. Its Connect returned, its write succeeded
into a buffer, and nobody ever read it — the launch was told it had rung the
doorbell and the window never came forward.
Reproduced in a container rather than reasoned about, because the two platforms
disagree and this machine is only one of them. Three rings back to back:
one instance, Disconnect between callers -> 3 of 3 answered
a fresh instance per connection -> 1 of 3 answered
So there is one server for the life of the doorbell now, Disconnect()ed between
conversations, and the socket is never not listening. Which makes leaveOpen on the
reader load-bearing too: a StreamReader disposes the stream it was given, and that
stream now has to survive into the next iteration.
Two things I had wrong about this, both worth recording. Cancelling
WaitForConnectionAsync *does* unblock a pending accept on Linux — that was my first
theory and the container disproved it, so DisposeAsync awaiting the loop was never
the problem. And the app itself never hung: the lost ring is a launch that is
ignored, not a process that stops.
◆ AND THE REASON IT COST TWENTY MINUTES RATHER THAN TWENTY SECONDS.
await twice.Task.WaitAsync(TestContext.Current.CancellationToken);
That token carries no timeout. A ring that never arrives is then not a failing test,
it is a job that sits there — which is exactly what happened, and it is the more
expensive of the two mistakes because it turns any future bug of this shape into a
runner somebody has to go and stop. Both awaits are bounded now.
TheDoorbellAnswersMoreThanOnce keeps its shape deliberately: two rings back to back
rather than one and a pause, because the gap between accepts is the whole defect. A
test that waited for the first to be answered would pass against the broken version.
ci.yml had no timeout-minutes anywhere, on any job. Every job has a ceiling now, and
the test step has a tighter one of its own — ten minutes against a suite that takes
two — because a hang there is the likeliest of the lot and a step-level timeout names
the step instead of leaving a reader to work out which of fifteen was still running.
None of them is a performance budget; a build that legitimately grows past one should
have the number raised.
Verified both ways on Linux, in a container, against the committed tree and this one:
|
||
|
|
b8c94ecb73 |
Put a window on screen while it starts, and refuse the second one
Two things a desktop application is expected to do and this one did not. ◆ THREE SECONDS OF NOTHING. Measured on a debug build against a cold profile: the main window arrives 3.1 seconds after the executable does. Nothing was on screen for any of it — the icon had been double-clicked and the desktop looked unchanged. The main window cannot come sooner. It needs a view model, and the view model needs composition to have opened the SQLite cache, bound the terminal's loopback socket, asked the platform about a TPM and read the install layout off disk. Its own IsStarting card already narrates what happens after it appears; this is the part before, which no arrangement of that window can cover. So SplashWindow, and it is measurably the point: 668 ms to the splash, 3.1 s to the window that replaces it. Two mechanics are load-bearing rather than incidental. The splash *is* desktop.MainWindow for a moment. The lifetime shows whatever that property holds once OnFrameworkInitializationCompleted returns; a window merely Show()n there would have no lifetime attached, and under ShutdownMode.OnLastWindowClose the first close of anything would end the process. Handing the property over is one assignment and Avalonia reads it lazily. And composition is posted at DispatcherPriority.Background rather than called. It is synchronous and slow — which is the entire argument for a splash — so running it inline would block the very dispatcher turn that has to paint the thing. The window is SizeToContent="Height", which is about the failure and not the splash: CouldNotStart carries an exception's own message, and inside a fixed height a long one would wrap and be clipped at exactly the moment that text is all the window is for. A composition that throws is caught rather than left to the dispatcher, for the same reason — before this there was nothing on screen to abandon, and now there is, so an escaping exception would leave a window saying "Starting…" over a process that had stopped. Escape closes it, and only then. ◆ AND ONE INSTANCE. Claimed in Main before Avalonia is configured, so a second launch starts nothing at all: no toolkit, no window, no splash flashing up before it thinks better of it. Exclusion is an exclusive handle on instance.lock inside the profile directory, and that location settles two questions at once. A nightly and a release build do not exclude each other — separate profiles, ADR 0013 — and two people signed in to one machine each get their own instance. A global mutex would have had to re-derive both from somewhere. A file handle rather than a named mutex because the kernel releases it however the process ends. There is no such thing as a stale lock here, which is a state a named mutex makes both platforms model differently and neither model simply. The second launch is not silent. Somebody who double-clicks an icon and sees nothing has been told the application is broken, so it rings a named pipe — named from a digest of the profile path, so it is scoped exactly as the lock is — and the running instance un-minimises and activates. Activate is a request and not a guarantee: Windows refuses the foreground to a process that did not receive the input, and the flashing taskbar button that results is still an answer. Three seconds of retrying when the lock is held by something that does not answer its pipe, and that window has one job. Velopack's apply ends this process and its updater starts the new build; the two are ordered, but a lock held a moment too long would make the successor refuse itself and vanish — an update that looked like an uninstall. The doorbell is disposed before Shutdown for the same reason, so the pipe name is free before the process is. ◆ VERIFIED BY RUNNING IT, WHICH IS THE ONLY WAY EITHER OF THESE COULD BE. Two launches of the built executable: the first stayed up with instance.lock in the profile, the second exited. Window sizes over time are where the two timings above come from — 420x160 then 1196x776. SingleInstanceTests covers what a single process can reach: the lock, its release by disposal, the doorbell answering more than once, and two profiles not excluding each other. SplashCardTests measures the one thing the splash can get wrong, which is a failure message long enough to need the height the window is willing to give it. Checks 1.12 and 16.11 are what needs a second executable and a real screen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |