- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
panel kept showing the previous vault's people under the new name. The read now starts regardless;
the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
Keychain names trim inside their column instead of printing over TYPE, and the Security page's
caption wraps inside its card.
- The Windows CNG key and the macOS keychain tag were one name for every profile, so signing out of
the nightly deleted the key the release build's device unlock was sealed to. DeviceKeyNames keeps the
existing name for the default profile and gives every other profile its own; sign-out deletes only
its own key, and a profile sealed before this keeps reading the shared one.
- DevicePinWindow named two palette resources that do not exist and loaded itself with
AvaloniaXamlLoader.Load, which never assigns the named fields, so it threw on construction and no
PIN prompt was ever shown. A layout test now builds it against the real palette.
- A message larger than the 64 KiB receive buffer was skipped and its tail dispatched as a frame of its
own. Messages are now reassembled up to 16 MiB; one beyond that is dropped whole.
- Input frames are handed to the pump without waiting, and ShellStream takes no lock around its write
buffer, so a snippet's text and its carriage return could interleave or lose bytes. Writes are
serialised per pump in arrival order.
- A resize reaching a shell SSH.NET had already disposed threw out of the receive loop and closed the
socket every tab shares. Each frame's failure now stays with its session.
- Pull hydrated every historical change entry with the item's current ciphertext under that entry's
old revision. The AAD binds the revision, so a client opened it at the wrong version, failed, and
parked its own pending edit as undecryptable. Entries an item has since moved past are left out; the
later entry carries the same payload under the right number, and a fresh device no longer downloads
an item once per edit it ever had.
- Deleting a team's last vault archived the team whoever deleted it, so an admin could archive it out
from under its owner. Only the owner's delete archives it now, and TeamService.ArchiveAsync checks.
- UserStatus was checked nowhere on the request path. ActiveAccountRequirement on both policies answers
403 account-not-active for a suspended or deprovisioned account.
- The hourly last-seen update moves xmin under the tracked user, and enrolment then saved that user
against the stale token: a 500 for anybody enrolling more than an hour after first signing in. The
entity is reloaded after the update.
- The outbox coalesces a new save into the row a push already read, under a new operation id, but
Complete, Revise and Park found the row by sequence alone. The acknowledgement of the earlier state
deleted the row holding the later one and the edit was gone without a word. They now take the
PendingOperation and act only while the row still holds it; a later edit is rebased onto the version
just accepted, so it pushes cleanly instead of conflicting with its own earlier save.
- A pull filtered by type moves the cursor past changes of types it did not ask for, so a build that
added a type (SpelunkKey, ObjectStore) never received the items of that type made before it.
sync_state.pulled_types records the list a cursor was read under, and a different list starts the
pull over once. Migration AddSyncStatePulledTypes.
- A remote name went straight into Path.Combine. A POSIX name may hold backslashes, a colon or a drive
letter, and an S3 key may hold "../", so a name chosen by whoever could write to the host decided
where on this machine a download landed. LocalDirectory.ChildPath cleans the name and requires the
resolved parent to be the target folder; every download path and EDIT use it, and a name that cannot
be a file there is left on the host and reported.
- EDIT handed the copy to the shell's default action, which for .bat, .hta, .js and friends is to run
it. LocalEditor opens such a file with its own "edit" verb or Notepad on Windows and `open -t` on
macOS, and refuses a .desktop launcher on Linux.
- A failed bucket upload leaves its part object, and a retry asked for an offset no bucket accepts, so
the row could never finish. IRemoteFileStore.CanWriteFromAnOffset is false for buckets and the queue
starts those over.
- The SFTP host picker re-found its selection from the empty moment of every vault reload and landed
on the first host once a minute; the chosen host is now remembered, and a typed password is only
sent to the host it was typed for.
Pins are stored per algorithm, so an endpoint pinned for ssh-ed25519 that was answered with only an
ECDSA or RSA key found no pin and raised SshHostKeyUnknownException: the first-use question, where a
pin exists to produce a hard refusal. Anybody in the middle needed nothing more than a key of a
different type.
The connection factory now reads every pin for the endpoint before the handshake, offers the pinned
algorithms first so a server that still holds its pinned key negotiates it, and in the gate accepts a
key under an unpinned algorithm only when its fingerprint matches one of the endpoint's pins (one RSA
key signs as rsa-sha2-256 and rsa-sha2-512). Anything else is a mismatch. IKnownHostStore gains
FindForEndpointAsync for this.
Settings → Security gains a SPELUNK section: an API address and a key, saved as a new keychain item, a Spelunk key (SyncEntityType 14, AadResourceType 17, server table spelunk_key via the AddSpelunkKeyItem migration). It is ciphertext only on the server, lives in the personal vault, syncs like every secret, and holds the key together with the one address it may be sent to. The key box empties once saved; the list shows name, address and how many hosts use each key, never the key. Saving a key for an address that already has one replaces it, and deleting goes through the shared confirmation card with the hosts that would stop connecting.
A host's route names a saved key where it named a credential, in the same apiKeyCredentialId field, so no host schema changes and an older build reads such a host as one whose key credential has gone. At connect a saved key is sent only to its own address; a host naming it with another Spelunk address is refused in words. Keys kept as a credential's password still work. The editor offers saved keys first and picks the one saved for an address as it is typed.
A link from a Spelunk with a saved key now connects straight away, including the first one: saving the key is the user's say-so that the first-link editor used to ask for.
The address and key merge as one pair, so two concurrent edits can never pair one device's key with another's address, and the key never reaches the conflict log.
A Spelunk route can now say that Spelunk signs the user in. Each connection makes a throwaway Ed25519 key pair in memory, sends the public half with the ticket request ({"signIn":{"publicKey":...}}), and logs in as the account Spelunk names (root when it names none) with that key, over the agent's one-connection sshd. Such a host needs no username, key, credential or typed password: the editor greys the login out and saves none, the password box and sheet stay down, and shells, file transfer and port forwards all take the key and account from the route. The flag is nested in the spelunk object at host schema version 10; hosts without it keep their version-9 bytes.
A dodossh://spelunk/connect link now connects: to the personal vault's host tagged spelunk for that server, or, when a saved host already sends an API key to that Spelunk, to a new sign-in host saved in the personal vault with that key and the spelunk tag (made if missing). Only the first link from a Spelunk opens the editor, pre-filled with sign-in ticked and the tag on, so the key credential is chosen once and never sent to an address no saved host already sends it to.
git.dodotech.cloud/dodotech-public/dodossh-api, under the repository's owner, the way Spelunk publishes its images. REGISTRY_USERNAME and REGISTRY_PASSWORD now have to be a Gitea account and a package token for that owner.
The API Dockerfile copied four of the five root build files before restoring and left out Directory.Build.targets, which pins Microsoft.NET.ILLink.Tasks. The image's locked-mode restore therefore asked for whatever ILLink its SDK bundled, and when sdk:10.0-noble moved to 10.0.401 (ILLink 10.0.12) it failed with NU1004 against lock files that correctly say 10.0.11 - while ci.yml, which sees the pin, stayed green.
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.
The Android head skipped a host's forwards because it had nowhere to show
what was listening. Its terminal bar now draws a ⇄ for a tab that has
forwards, carrying the listening count in green and turning the danger
colour when one failed, and it raises a sheet over the same rows the
desktop sidebar lists: notation, route, state, failure reason and a tap
to switch each off or on. MainWindowViewModel.StartForwards no longer
returns early on Android.
The sheet is shell state like the connect sheet: it collapses the
renderer while up, is lowered by leaving the terminal, and the back
gesture closes it first.
Forwards ride the shell's keep-alive rather than holding the foreground
service themselves, since a forward cannot outlive its shell. They only
change the notification, which now counts ports forwarded beside shells
connected, fed by a new ForwardsChanged event and ListeningForwardCount.
The phone still has no editor for forwards; the More screen card, README
and design-import-gaps now say they run here and are edited on the
desktop.
A host can now carry port forwards (-L, -R, -D), saved inside its
encrypted payload as RelayTicketRequest.PortForwardId already assumed,
so they sync and merge per forward like tags and pinned paths. The host
schema moves to version 8 only for hosts that carry forwards.
ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports.
Every listener binds loopback and there is no bind-address field: a
forward is something any member of a shared keychain can edit. A remote
forward's target is resolved here, IPv4 first, rather than by SSH.NET's
constructor, which takes the first DNS answer and can send ::1.
Forwards start with every terminal opened to the host and stop with its
connection; a reconnect starts them again. The session sidebar lists
them with state, failure reason and an on/off switch, the status bar
counts the listening ones, and the host editor and detail pane gain a
PORT FORWARDS section. A forward that cannot start fails alone.
The phone does not start forwards yet: it has no surface showing what
is listening, and its More screen now says so. No byte counts, since
SSH.NET keeps none.
The events endpoint cancelled its pump token and only then wrote the close
frame. The token was also the one passed to socket.ReceiveAsync and
SendAsync, and cancelling a pending operation on the ManagedWebSocket that
Kestrel hands over aborts it. CloseAsync then saw an Aborted socket and
returned, so in production a client whose token expired got a TCP reset
rather than 4401 "reconnect with a fresh token", and a shutdown sent no
1001 either.
The tests missed it because TestServer's in-memory socket survives that
cancellation: the same scenario through TestServer closes with 4401.
PumpAsync now does what the relay's pump does. No WebSocket operation is
cancelled; the token stops only the outbound queue, the clock and the
access refresh. The sender is let finish its last write, the close goes
out, and the receiver ends on the client's answering close. Each wait is
bounded by the close timeout, and the socket is aborted only when a peer
stops answering.
ApiFixture gains ConnectEventsOverTcpAsync, which runs a second host on
real Kestrel over loopback, sharing the container and identity stub. The
new AnExpiringToken_ClosesTheSocketWith4401 fails with a reset before this
change and passes after it.
The relay checkbox said it was not wired up, and the plan said the server
half was built and shipped. It was not: there was ADR 0004, the contract
records, RelayOptions and a CHECK constraint, and RelayOptions said in its
own remarks that there was no endpoint and no dialler. So both halves are
built here, as step 3 of docs/reaching-a-host-you-cannot-dial.md.
Server, in Features/Relay:
- POST /api/v1/relay/tickets makes every decision: the host, Connect on
its vault, the relay switched on, and the stored name resolved once and
judged by RelayAddressPolicy. Loopback, link-local, 0.0.0.0/8,
multicast and the metadata endpoints are refused with no configuration
escape, with IPv4-mapped and NAT64 spellings unwrapped first; then the
operator's denials, allow-list and private-network switch. What passes
goes into a Data Protection ticket, and nothing resolves the name again.
Every target refusal is the same 403, so the endpoint cannot be used to
map the operator's DNS; the real reason is logged.
- GET /api/v1/relay/connect takes the ticket as a second subprotocol,
checks its expiry against TimeProvider and spends it with a unique
insert into relay_ticket_use before accepting the upgrade. After the
upgrade a refusal is a close code: 4502 for a host the server could not
reach, 4429 for a limit, 4408 idle, 4409 lifetime, 1001 after a drain.
- relay_session is the audit row, written from short-lived scopes.
Client:
- RelayBridge is a loopback SOCKS5 listener that accepts once and pipes
to the relay socket. It rides on the new SshLoopbackProxy.Lifetime, so
the connection disposes it on every way a session ends and a failed
connect disposes it before throwing.
- ServerRelay, exposed as IVaultServer.Relay from /api/v1/meta, asks for
the ticket, opens the socket, starts the bridge and turns every refusal
into a sentence.
- VaultViewModel and TransfersViewModel reach a relayed host through it,
never fall back to dialling it directly, and replace SSH.NET's transport
error with the relay's own reason when it gave one.
Neither side ever cancels a WebSocket operation. Cancelling one aborts
the socket, and an aborted socket sends no close frame, which is the only
way the client learns the server could not reach the host.
AnIdleSession_IsClosedWithTheIdleCode asserts that the code arrives.
Three departures from ADR 0004, recorded in it: awaited copy loops rather
than Pipelines (same backpressure, two 32 KiB buffers), no ?ticket=
fallback, and no device id in the ticket.
Tests: API 245, shell 582 (5 skipped), layout 170, SSH 110 including a
real login through the bridge, API client 30, contracts 25. Not run: the
Android build (its change is copy only) and the Alpine layout oracle.
TheClipboard_ReachesThePaneThatAskedForIt failed in CI on an opcode of 2
where it wanted 8: a second SessionOpened frame where the clipboard's
should have been. Only one thing sends a session a second SessionOpened,
and that is ReplayAfterAttachAsync, the replay a reattaching page gets.
It arrives there because the replay is fired and forgotten from the
socket-accept thread immediately after the attach is signalled, and the
test helper returned on that signal. Deschedule the accept thread for a
timeslice between the two -- which a loaded CI box does -- and the test
opens its session first, so the replay finds that session live and sends
it a replay-flagged SessionOpened after its real one.
Nothing is wrong with the workspace. The page treats a replay onto a pane
it still has as idempotent, and ReplayAfterAttachAsync's own remarks
already say racing a fresh page's first frames is harmless. What was
wrong is a helper that waited for the attach and then let its callers
count frames as though the attach were finished, so ConnectRendererAsync
now waits for RendererReattached as well -- raised as the last thing the
replay does. The remark there argued a replay could not be sent early; it
missed that it can be sent late, and now says so.
The same late replay could reset the credit window out from under
ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits, which shares the
helper, so that latent flake goes with it.
Verified by delaying the replay's snapshot by a millisecond: the old test
fails under that, and the fixed class passes it eight runs running.
A tab remembered what its host was called and nothing else, and the reconnect
under a stopped tab looked the host up by that name: HostFor took the first row
in the keychain whose label matched. Nothing in this application stops two hosts
being called the same thing — a team's vault beside a personal one makes it
ordinary, and "prod-db" is what the database server gets called in both — so the
button dialled whichever namesake sat earliest in the list. A dropped session on
one machine came back as a live shell on another, with the tab, the address and
the status bar all still naming the first. There was nothing on screen to read
the substitution off.
The name match was not an oversight; it was an argument, written into the remark
on HostFor and repeated on RefreshConnectedHosts: a tab outlives the vault that
opened it, so it "cannot hold an entity id that would still mean anything after a
lock". That is not so. EntityId is the item's own sync identity rather than a
reference to a row object, so a vault that is locked and opened again rebuilds
every row around the same ids. The one handle that was rejected as too fragile to
survive a lock is the handle that survives it.
So the id now travels with the connection. ConnectionAttemptEventArgs and
TerminalSessionEventArgs carry it, taken from ConnectionTarget.HostId, which the
connect path has had all along for the log; TerminalTabViewModel keeps it; and
HostFor matches on it. A host renamed since the tab opened is found too, which a
name match by definition cannot. Null is not missing data — it is the whole of
what makes a typed-in address different, and it is why such a tab is refused a
reconnect rather than offered one that would dial a stranger sharing its name.
Three other lookups had grown their own copy of the same match and so had the
same fault, where it is read rather than dialled: the green connected dot, which
lit on every machine answering to the name and claimed a shell on hosts nobody
had connected to; the pinned paths QUICK ACCESS offers, which come from whichever
row that same match found; and the sidebar's "Pin folder" and the SFTP tab row,
which would have edited and browsed the wrong host. All four now go through
HostFor, so "which machine is this tab" has one answer.
Both new tests assert on the address actually dialled rather than on anything the
tab says about itself. The two rows agree on every string a tab carries, which is
exactly why this went unnoticed: a test written against the label would have
passed on the day the wrong machine was being opened.
The terminal's memory was bounded by an argument that is not true of the SSH
library underneath it, and the whole of CreditWindow's remark was that argument:
the pump stops reading when the renderer falls behind, SSH's own receive window
closes, the remote sshd blocks on write, and the backpressure reaches the process
producing the output. The middle step does not happen. Renci.SshNet's
Channels.Channel.OnData calls its window adjustment before it raises DataReceived
and re-opens the channel window as soon as it drops below one packet, and
ShellStream's own handler copies the bytes into a System.Net.ArrayBuffer without
blocking and without a ceiling — a buffer that doubles on demand and is not given
back until the stream is disposed. So a reader that stopped reading slowed the
remote down by nothing at all. It only chose whose buffer the flood landed in,
and it chose the one nothing bounds.
Measured against the container this repository already runs sshd in: a reader
that reads nothing while `yes` runs leaves ShellStream holding 8.9 MB after one
second, 26.8 MB after three and 44.3 MB after five, its capacity stepping 16 → 32
→ 64 MB. That is the report of "a massive memory leak on Windows"; it is not a
Windows fault, it is simply where long sessions with heavy output live.
The fix is to read continuously, which is what keeps SSH.NET's buffer empty, and
to put the bound where it can actually be enforced. OutputBacklog is that bound:
a byte-capped queue of unsent output, 4 MiB by default, which drops its oldest
bytes when full and counts what it discarded. Oldest rather than newest, because
the tail of a flood is the prompt somebody is waiting for.
The two bounds are now separate and each is enforced where it means something.
The backlog bounds what is waiting to be sent. The credit window goes on bounding
what has been sent and not yet drawn — the queue inside the WebView that nothing
on this side can measure — and is reserved against in the flush loop rather than
the read loop. Reading is part of neither, because reading was never what the
remote was waiting on.
Dropping is announced rather than silent. A terminal quietly showing a different
transcript from the one the remote sent is worse than one that says it could not
keep up, because the second can be believed. The line goes out under its own
opcode, TerminalServerOpcode.OutputDropped, rather than spliced into the output
stream: what this application says about the transcript is not part of the
transcript, and keeping them apart is also what makes the arithmetic hold — every
byte read is either in an Output payload or counted in BytesDropped. It opens
with an SGR reset, because the cut lands at an arbitrary offset and whatever
escape sequence the remote was in the middle of has been left half-said.
Frames are now capped at 64 KiB and taken straight into the frame buffer. The cap
is the large-object-heap threshold rather than a guess — a quarter-megabyte array
allocated sixty times a second is collected only by a generation 2 pass — and the
direct take removes an allocation and a copy of every byte the terminal shows.
The second half is the one that made the first half's cost persist. Nothing gave
a session back when the remote hung up: TerminalWorkspace kept the entry, and
with it an undisposed ISshConnection — an authenticated socket, its cipher state,
and a keep-alive putting a packet on the wire every thirty seconds — plus the
pump and SSH.NET's read buffer at whatever size the session's busiest moment had
grown it to, for as long as the tab stayed open. A tab somebody typed `exit` into
an hour ago was indistinguishable from a live one in everything except what it
drew. ReleaseAsync disposes both, from AnnounceEndedAsync and after the
announcement: the tab is told first, because tearing down an SSH connection in
front of that would be latency on the one thing the user is watching for. The
dictionary entry stays, because LiveSessionCount, IsSessionLive and the tab's own
ENDED and RECONNECT all read it. SendInputAsync therefore asks about liveness
rather than presence, since the shell channel is gone the moment the remote is.
Two faults introduced on the way here and fixed, both worth naming because
neither would have been visible from the outside:
The read loop left through a condition on its cancellation token, and RunAsync
reads a read loop that returns as the remote having closed the channel. A tab the
user closed would have reported itself as a shell that exited on its own. It
throws instead.
With the reader finished and a renderer that had stopped acknowledging, the flush
loop span: output to send, no credit to send it with, and a wait for more output
that returned at once because there was some. RunAsync never completed, so the tab
was never told and the connection was never released — a session invisible and
permanent. The loop now leaves for the drain as soon as the backlog is complete,
and RunAsync resets the credit window alongside completing it so a flusher already
parked on an acknowledgement that will never come is released.
Four tests asserted the old contract and now assert its reverse, which is the
point rather than a relaxation — WhenCreditRunsOut_ThePumpStopsReading is now
WhenCreditRunsOut_ThePumpKeepsReadingAndTheBacklogHoldsTheLine, and has to prove
both halves at once. The throughput harness gained a counting transport, because
a consumer that copies every frame into a 32 MB list was the slowest thing in the
pipeline and a harness whose consumer cannot keep up measures itself. The fake
shell gained a pace, so "a renderer that keeps up loses nothing" can be asked of a
producer a network could actually deliver rather than of one running at memory
bandwidth.
The test that would have caught this in the first place is
ARunawayRemote_KeepsBeingReadAndStaysInsideTheBacklog, and it needs the real
SSH.NET and the real sshd to say anything: a fake shell hands over bytes when it
is asked, and a real channel hands them over when they arrive. Under the old code
its BytesRead would have stopped at exactly WindowBytes.
121 terminal tests pass over three consecutive runs with no flakes, 105 against
the container, and the app, session, layout and remaining suites are unchanged and
green. Manual checks 1.10a and 1.10b cover the two symptoms a person can see: a
working set that plateaus under `yes`, and a connection that goes when the shell
does rather than when the tab does.
Three scripts prompted for a secret with Read-Host -AsSecureString or read -s,
said in a comment that it therefore never lands in shell history, and then wrote
it straight into the arguments of the next process. The comment was true and was
answering the wrong question. A command line is not private: on Windows one call
to Get-CimInstance Win32_Process hands every argument of every process running as
this user to anything that asks, /proc/<pid>/cmdline is world-readable on Linux,
and process-creation auditing and EDR agents keep their own copy either way. The
values were the Android release signing key's passwords and the forge token —
between them the whole capability to ship an update that every installed copy
will fetch and run, which ADR 0011 rule 1 and ADR 0013 go to some length to keep
off a runner and which was then left in a process listing on a desk.
Both replacements are documented features of the tools rather than tricks.
The Android passwords use .NET for Android's env: prefix, which puts the name of
a variable on the command line and lets MSBuild read the value out of the
environment itself. Set immediately before the build and cleared in a finally, so
a failed signing does not leave them in the shell for whatever runs next, and
-nodeReuse:false so no persistent MSBuild worker outlives the call still holding
the inherited copy in its own environment.
That prefix is unsupported when AndroidPackageFormat is aab, and the failure is
silent — the package would be signed with the literal string
"env:DODOSSH_ANDROID_STOREPASS" as its password. So the format is read back and
the build stops if it is ever changed, in the same shape as the versionCode guard
a few lines above: a wrong release refused here rather than discovered later on
somebody's phone.
vpk documents VPK_TOKEN as --token's environment fallback, so the flag is simply
gone from both desktop scripts and the token never becomes an argument at all.
Unset afterwards on the failure path too, which on macOS needs the exit status
captured rather than a bare || stop_with.
release-macos.sh had the same fault and was missed when the other two were first
reported. Its Apple credentials were already right — a notarytool keychain profile
rather than --password — and only the forge token was exposed.
What this does not claim: an environment block is readable by a process holding
rights over this one, the same as its memory is. The comments say so rather than
implying the values are now safe. What is bought is real and narrower — off the
command line, out of every process listing, out of the audit trail, and out of any
log that echoes the invocation.
Both PowerShell files parse, all three shell scripts pass bash -n, the
AndroidPackageFormat probe evaluates and answers empty so the guard passes, and
dotnet build accepts -nodeReuse:false. The signing itself is not exercised here:
it needs the release keystore, which by design is not on this machine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A scan of the working tree and all 335 commits for anything that should not be
public found no leaked secret. What it did find was three places where a reader
is told something the repository does not back up, and in a public repository a
confident wrong note is worse than silence: nobody goes and checks a sentence
that sounds settled.
The ignore list stopped at *.pfx, *.p12 and *.snk, so a keystore was not covered
at all. The one that matters is the release keystore ADR 0011 rule 1 keeps off
runners — drop it in the tree while cutting a release and nothing would have said
a word. *.keystore and *.jks now, with build/nightly.keystore negated: an ignore
rule cannot untrack a tracked file, so the negation changes no behaviour and is
there so the next reader does not take the committed one for an accident. ADR
0014 is cited beside it, because "a signing key is committed here on purpose" is
a claim that needs its argument within reach.
Also gone from that list: a negation for deploy/.env.example, which has never
existed in any commit.
RelayOptions.AllowPrivateNetworks said loopback, link-local and cloud metadata
ranges "are denied unconditionally and are not configurable" — present tense, in
the voice of a control that exists. Nothing reads the setting. There is no relay
endpoint and no dialler; the relay is ADR 0004 and a contract type. So the
sentence most likely to be read by somebody auditing this repository's SSRF
posture was describing code nobody has written. It now says that outright, and
hands the obligation to whoever builds the dialler with ADR 0004 §3 named in
full, including the IPv4-mapped IPv6 re-check, which is the clause a fresh
implementation is likeliest to miss. The flag governs RFC1918 and never that
list, which was the other half the old wording ran together.
docker-compose.dev.yml pointed at deploy/docker-compose.yml as "the production
stack". That file has never existed here either. Says what does ship — the image
the CI image job builds and pushes — and puts a compose file to run it where it
belongs, in M4's self-hosting guide.
Comments and an ignore list only; no behaviour moves. The API builds clean, and
the two tests that mention the dev compose file read its Postgres version, which
is untouched.
Left alone deliberately: release-android.ps1 and release-windows.ps1 pass the
keystore password and the forge token as arguments to a child process, where
another process on the machine can read them off the command line. That is a real
if small hole and a code change rather than a comment — the vpk half needs
somebody to find out whether it takes the token from the environment first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The progress bar went in "Take the frozen bar off the splash, and leave the line
that is true" and the manual check still told a person to watch it move, and then
to watch it stop. A check that names something not on the screen is worse than no
check: whoever runs it has to decide whether they are looking at a regression or
at a stale document, and the honest answer is not available from the page.
Says what the failure state looks like now — the line changes and nothing else on
the card is there to contradict it — and records where the bar went, so the next
reader is not left wondering whether it was lost by accident.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card that fills the terminal's rectangle while a connection is being made
prints two lines saying which machine and at what address, and both were
TextBlocks — which in Avalonia is a drawing of a string rather than a string.
It is the one place the shell says what it is dialling *before* anything has
answered, and on a refusal it is the only copy of that on screen: there is no
prompt yet and there may never be one. That is exactly when the value is wanted
somewhere else — in a ticket, in a message to whoever owns the host, in the ssh
command being written in the window beside this one — and retyping it off the
screen is how a digit goes missing from an address that is mostly digits.
SelectableTextBlock, the same change the session column's own address got in
"Let the session address be selected, and copied whole" and for the same reason.
Nothing else about either line moves: same font, size, weight, colour, trimming
and centring. The refusal underneath them was already selectable, which made the
pair above it the odd ones out.
Both lines rather than only the name. They are two lines of one identifier, and a
pair where the first can be taken and the second cannot is a worse answer than
either.
Tooltips are new, and they are what makes the trimming safe rather than a trap.
A drag reaches only the glyphs that were drawn, so a name or an address wearing
an ellipsis has to be taken with select-all — which takes the whole value,
because the ellipsis is a drawing and not a shorter string. The hover says what
the whole value is, so nobody copies half of one without having been told there
is a half. Same argument, same shape, as the sidebar's.
The test asserts that half rather than the easy one, on values long enough to be
trimmed inside the card's 460 pixels; values that fit would pass whether the
trimming were render-only or not. Confirmed it bites by putting the two
TextBlocks back and watching it fail.
The phone draws the same two lines in the same state — TerminalScreen.axaml's
IsConnectingShowing block — and is deliberately left alone. Its captions are
styled by TextBlock.title and TextBlock.detail selectors in Phone.axaml, so the
same substitution there silently drops the styling until those rules gain
SelectableTextBlock siblings, which is a theme change rather than this one. Worth
doing; not worth smuggling in here.
Also: WindowDecorations on the splash instead of SystemDecorations, which
Avalonia 12 has deprecated in favour of it. That warning was mine, added two
commits ago; the main window's own is older and is not this change's to move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The indeterminate bar did not move, and it was never going to. Composition is
a synchronous block and DodoSshApp posts it to the very dispatcher that would
have to animate it: Background priority buys the splash its first paint and
nothing after it, because once composition starts the UI thread is inside it
until the main window is ready, and a ProgressBar's indeterminate animation is
driven by that thread's clock. One frame, then ~2.4 seconds of stillness — the
whole of the time the window is on screen.
A frozen indeterminate bar does not read as indeterminate. It reads as a
determinate bar stuck at zero, which is worse than no bar at all: it says the
work has stalled at the moment the application is busiest, which is the one
thing a splash must never say.
Freeing the UI thread would have been the other fix, and it is a real one —
the I/O in Compose has no business on that thread. It is also a rework of
startup for the sake of an ornament. There is nothing here that can honestly
be measured anyway: a SQLite open, a loopback bind, a TPM probe and an
install-layout read report no progress between them. So the bar goes.
"Starting…" was already on the card, it is true for as long as the card is up,
it needs no clock to stay true, and the window closing is what says it
finished. CouldNotStart is one assignment now — it used to have to stop the
bar as well, so that nothing went on claiming to work over a process that had
died, and there is no longer anything on the card that could.
The window keeps its MinHeight, which is what stops two lines of content
collapsing it, and the failure path keeps SizeToContent for the same reason it
always had it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against SSH.NET 2026.0.0 before anything was written, because the
premise needed checking. Four ways of failing to reach a host, and what this
client puts in front of the user for each:
server accepts TCP, never sends a banner -> "Connection has timed out."
packets black-holed, nothing comes back -> "Connection has timed out."
nothing listening on the port -> "...actively refused it."
name does not resolve -> "No such host is known."
The first two are the same five words and are not the same problem. One is a
network, a firewall or a wrong address; the other is a machine that is up and
well and an sshd that is wedged, or a port that is not SSH at all. Nothing in
the exception tells them apart, and that sentence was the whole of what this
product could say about the failure people hit most.
◆ A SOCKET ANSWERS IT, IN ABOUT AS LONG AS IT TAKES TO OPEN ONE.
SshReachabilityProbe resolves the name and opens a bare TCP connection, and the
four answers it can come back with are the four somebody would do something
different about — unresolved, refused, silent, open. Every one of them is what
a socket actually did, measured at the moment it was asked, rather than
anything inferred from the exception that started it.
Deliberately nothing else. No ping: ICMP is filtered on most of the networks
this is pointed at, so an unanswered one would be evidence of nothing. No scan
of neighbouring ports — the question is what happened to the connection the
user asked for, and probing ports nobody asked about is how a client gets
flagged by an intrusion detector on the very network somebody is trying to
work on. No second SSH handshake against a server that has already failed one.
The budget is six seconds and the floor under it is not a guess: a refusal is
not immediate on Windows, and a closed loopback port took ~2s to answer when
this was measured, because the stack retries the SYN first. At two seconds the
probe calls a machine that is up and refusing a firewall. The test for that
case runs at the real budget on purpose, so lowering the number fails it.
◆ ONLY WHILE REACHING, AND THE WINDOW IS CLOSED AT BOTH ENDS.
Past SshConnectionPhase.Reaching the machine has offered a host key, so it has
demonstrably been reached and a refused password is not a reachability
question. Before it, nothing has been dialled at all — a private key that will
not parse throws out of BuildConnectionInfo before the factory reports
anything — and probing there finds a healthy port and appends "the SSH
handshake is what did not finish" to a message about an unreadable key. That
is a confident wrong answer replacing an accurate one, so both ends are shut.
PhaseReporter keeps the furthest phase to decide it.
The finding is added to what SSH.NET said rather than replacing it: the
exception is the fact, and this is a reading of a second measurement.
Every shell in ShellFlowTests now gets a scripted probe. Without one a failed
connection in any test in that file would do a live DNS lookup for db.internal
and open a real socket — slow, network-dependent, and worse than either, since
db.internal resolves on any machine whose resolver has a search domain that
makes it, which would make the asserted sentence depend on whose network the
suite ran from.
◆ WHAT THIS DOES NOT FIX, FOUND WHILE READING THE SAME PATH.
- SshConnectionRequest.ConnectTimeout is documented, plumbed, and passed by
nothing. Every connection this client makes is hard-wired to the 15-second
default, and there is no per-host field and no setting that can change it.
Fifteen seconds bounds the handshake waits as well as the connect — a
server that accepts TCP and never sends a banner fails at exactly the
timeout, measured — where OpenSSH's own ConnectTimeout covers only the
connect. If timeouts are frequent on slow links, this is the first
suspect, and it needs a product decision rather than a patch.
- The known-host lookup in HostKeyGate.OnHostKeyReceived blocks SSH.NET's
message thread inside that same 15-second budget. The blocking is
acknowledged in the code; that it competes with the key exchange's own
timeout is not. On a cold or locked vault a slow read would surface as a
connection timeout, which is exactly the symptom above.
- ConnectThroughHostKeyGateAsync never unsubscribes HostKeyReceived on
success, so the gate outlives the handshake holding the connect-time
CancellationToken. If SSH.NET raises that event again on rekey it would
re-enter the blocking store read with a token whose source is long gone.
Not verified either way — it needs a real sshd and a forced rekey.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>