Commit Graph
260 Commits
Author SHA1 Message Date
jaap-jan d71f057bde SFTP: stop waiting for ever on a host whose login prints text, and draw the file panes' icons whole
ci / build and test (pull_request) Successful in 2m40s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 7s
ci / deploy ssh.dodotech.cloud (pull_request) Skipped
ci / android head (pull_request) Successful in 3m43s
SSH.NET 2026.0.0 runs the SFTP handshake synchronously inside ConnectAsync, with an infinite
operation timeout and without reading the cancellation token. A host whose login script prints
anything (an echo in .bashrc) puts text where the first SFTP packet belongs; SSH.NET reads its first
four bytes as a length of about a gigabyte and waits for the rest, so the file browser said
"Connecting…" for ever. The SFTP client now uses the connect timeout as its operation timeout for
the handshake and resets it to infinite once connected, so transfers on slow links are unchanged. A
timeout after authentication becomes a TimeoutException naming the likely cause.

The SSH test server gains a second port whose sessions print a banner before sftp-server, and
SftpSessionTests proves the open fails within a 3 s connect timeout with that message. Without the
fix the test runs into its 30 s guard.

Button.paneicon is a fixed 26x26 but kept Fluent's 8,5,8,6 padding, which left a 10px-wide content
box, so every 15-16px Material icon on the SFTP panes was drawn with its sides cut off. Its padding
is now 0. Rename on the host pane used U+E9AD, an italic T (text formatting); it now uses U+E9A2,
the rename glyph the phone's files screen already uses. The host pane's "Up one directory" drew the
design's arrow_downward; both panes now use arrow_upward.

SSH tests 123/123, layout tests 203/203, app tests 744/749 with the 5 Linux TPM skips.
2026-10-16 15:09:46 +02:00
jaap-jan 3c8c0980c2 Terminal: ask what to connect to when Terminal is pressed with nothing open
ci / build and test (push) Successful in 4m13s
ci / android head (push) Successful in 4m24s
ci / api image (push) Successful in 13s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m6s
The terminal surface with no session used to be an empty frame, with the tab row's "+" the only
thing on it that did anything. It now holds a prompt, in the order ssh asks: "connect to:" takes a
saved host's name, matched exactly, with the hosts that match so far listed under it for Tab or a
click, or user@host[:port] for a machine that is not saved. A password line follows only when one
is needed, worded as ssh words it. Escape steps back a line; recent connections are listed below.

It is drawn by Avalonia in the WebView's rectangle, which the occlusion rule allows because the two
are exclusive: the prompt shows only while there are no tabs, the WebView only for a tab with a
session.

Saved hosts connect through a new VaultViewModel.ConnectToHostAsync, which the drawer's own connect
now goes through too, so every refusal is still made once. Each attempt now carries whether it may
act on the drawer's remember-password tick, and the prompt's may not: otherwise a tick left on in
a closed drawer would save a password typed at a prompt that never offered to keep anything.

Covered by nine ShellFlowTests cases and four ConnectPromptTests; manual check 7.10b.
2026-10-16 12:17:34 +02:00
jaap-jan 5b2e3f3651 Android: type user@host into the hosts search to open a terminal on a machine without saving it
ci / android head (push) Successful in 4m22s
ci / build and test (push) Successful in 4m39s
ci / api image (push) Successful in 7s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m20s
The phone's only unsaved way in was the box on the terminal screen, which is drawn only while nothing
is open, so a second machine could not be reached that way while a first one's session was up. The
search well at the top of HOSTS is always there and the design already calls it "Search or
connect...", so it now reads an address the way the desktop palette does: with an @ in it, a NOT
SAVED card above the list offers a password box and CONNECT.

It connects through VaultViewModel.ConnectToEndpointAsync, the same path as the manual box and the
desktop palette, so nothing is written to the keychain. The search box and the password are cleared
once it dials, the password goes whenever the address changes, and an address the manual parser
refuses gets its sentence under the box.

Covered by six new ShellFlowTests cases; manual check 8.20 covers the card on a device.
2026-10-14 17:27:57 +02:00
jaap-jan 988248fa57 Quick connect: type user@host into Ctrl+K to open a terminal on a machine without saving it
ci / build and test (push) Successful in 5m48s
ci / api image (push) Successful in 47s
ci / deploy ssh.dodotech.cloud (push) Failing after 2s
ci / desktop nightly (push) Successful in 3m26s
ci / android head (push) Successful in 10m2s
The desktop had no way to reach a machine that was not in the keychain; the phone has had a manual
box on its terminal screen all along. The palette's query now reads as an address when it has an @
in it: a "not saved" row offers to dial it, Enter asks for the password in place, and a second Enter
connects. Escape on the password step goes back to the address.

It goes through the phone's own path, now split out as VaultViewModel.ConnectToEndpointAsync, so the
tab, the host-key question and the connection-log entry are the same on both heads and nothing is
written to the keychain. Password only, as on the phone: a key is the reason to save the host.

The typed row takes the highlight unless a saved host dials exactly that address, so typing a new
address cannot land on a saved machine whose address merely starts the same way.

Covered by eleven new QuickConnectTests; manual check 7.10a covers the real dial.
2026-10-14 17:02:26 +02:00
jaap-jan 509937c4b8 Sync: keep the remembered sign-in when the provider is merely down, and say why SYNC NOW found the machine offline
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 4m10s
ci / build and test (push) Canceled after 4m12s
2026-10-07 11:11:09 +02:00
jaap-jan 6806e542f9 Terminal: measure characters with Unicode 11 widths, so an emoji takes the two columns the remote counts
ci / build and test (push) Successful in 3m37s
ci / android head (push) Successful in 3m45s
ci / api image (push) Successful in 8s
ci / desktop nightly (push) Successful in 1m44s
2026-10-04 13:55:20 +02:00
jaap-jan e240c0cf26 Tests: prove the device-key probe runs off the caller's thread by blocking it, not by comparing thread ids
ci / build and test (push) Successful in 3m25s
ci / android head (push) Successful in 3m55s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 23s
TheChoiceIsMadeOnce_OffTheCallersThread failed on CI with the code doing the right thing. It
compared the probe's managed thread id with the test's, but the test runs on a pool thread, and that
same thread is free to pick up the queued probe as soon as the test awaits.

The probe now waits on a release the test only gives once construction has returned. A probe run on
the constructing thread could never be released, and the test fails after ten seconds instead of
passing by luck. Twenty runs pass; an inline probe fails it.
2026-10-03 19:50:25 +02:00
jaap-jan 96dd176121 CI: move vpk and Velopack to 1.2.161, whose Gitea upload survives the forge's new release responses
ci / build and test (push) Failing after 2m54s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m41s
The desktop nightly has failed on every push to main since 2 October, at 'publish the nightly
release'. vpk 1.2.0 creates the draft release and then reads release.Assets from the forge's
answer, which the upgraded server (28.0.0) no longer fills in for a new release, so it threw a
NullReferenceException at GiteaRepository.cs:136. The step deletes the previous nightly-desktop
release before it uploads, so desktop clients have had no nightly to update from since.

1.2.161 reads the assets null-safely. The library moves with the tool, since Velopack expects the
two to match. Every vpk option the workflow and the release scripts pass is present in 1.2.161;
the only renamed ones are two MSI options nothing here uses.
2026-10-03 19:44:25 +02:00
jaap-jan 367cebae9b Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
2026-10-03 14:29:22 +02:00
jaap-jan 858e36279d Sync: after a read from the beginning, remove what the server no longer has, and flag an edit that brings a forgotten item back
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m46s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 48s
The server now forgets a deletion past its retention and refuses cursors
from before it. The client then reads everything again, but everything
again says nothing about what went. So a machine away for longer than the
retention kept those items for good, and its next edit of one put it back
on the server for everyone.

A read from the beginning now marks each item it reads, keeping the mark in
the sync state because the read can span passes. When it finishes, it
removes the items of the synced types it never marked. Two kinds are kept:
items newer than the read's bound, which this machine pushed during it, and
items with a change still waiting, which are the user's work. For those,
the push finds the server without the item and sends it as a create, as
before, but now records a RecreatedAfterCollection conflict and counts it,
so the sync message says so instead of undoing someone's deletion silently.

The bound is the highest sequence the cache held when the read began, or
anything higher it has read since. Taking only what the read returned would
have left the last item of a vault in place for ever.
2026-10-02 17:36:11 +02:00
jaap-jan 55e5de0ce4 Server: a cursor carries the sync horizon it was issued under, so a read from the beginning after a collection is not refused on its own pages
A cursor behind the horizon was refused on position alone. But the read a
refused client starts from the beginning is itself behind the horizon on
every page until it passes the collected deletion, since older untouched
items come first. So its second page was refused, it started over, and it
never finished, on any vault larger than one page.

The cursor now records the horizon read before the page that produced it,
and only a cursor issued under an older horizon is refused. A cursor issued
under no horizon keeps the v1 format, so a server from before this still
reads it.
2026-10-02 17:36:03 +02:00
jaap-jan 8cb1457ca9 Server: collect tombstones past their retention, compact superseded change entries, and refuse cursors from before a collection 2026-10-02 17:18:22 +02:00
jaap-jan 656a33b4b1 Desktop: build only the host cards near the view, with a virtualising wrap panel for the board
ci / build and test (push) Successful in 3m39s
ci / android head (push) Successful in 3m59s
ci / api image (push) Successful in 5s
ci / desktop nightly (push) Failing after 37s
2026-10-02 16:20:51 +02:00
jaap-jan 24c18205c3 Desktop: rebuild the hosts board once the typing in the find box pauses, not once a letter 2026-10-02 15:23:58 +02:00
jaap-jan 8759b12d47 Desktop: virtualise the transfer queue and the import lists 2026-10-02 15:14:34 +02:00
jaap-jan 7b072bcded Desktop: swap the host lists in whole instead of announcing every row to listeners that redo all their work per row 2026-10-02 15:07:08 +02:00
jaap-jan a2362d1e3e Desktop: decide a titlebar double-click on the press, before the drag can swallow it
ci / build and test (pull_request) Successful in 2m48s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Successful in 13s
ci / android head (pull_request) Successful in 3m41s
2026-10-02 14:40:50 +02:00
jaap-jan 079a273d46 Desktop: let a lock pressed during an unlock win, instead of the unlock finishing behind it 2026-10-02 14:37:52 +02:00
jaap-jan 37e9d74938 Desktop: keep a vault-visibility failure from closing the app, and leave a crash log when anything else does 2026-10-02 14:28:27 +02:00
jaap-jan 170caa1391 Relay: end a user's sessions into a vault the moment they lose access to it 2026-10-02 14:26:44 +02:00
jaap-jan ff8a790f6c Sync: take the server having no such item as a delete done, not a rejection nobody can clear 2026-10-02 14:18:28 +02:00
jaap-jan b2516ca7fb Server: start a retried write transaction from the database, not from the attempt that failed 2026-10-02 14:15:52 +02:00
jaap-jan 36fb0817a4 Transfers: say so when a drop or a download is refused because the screen is busy 2026-10-02 14:11:41 +02:00
jaap-jan 8bee476039 Transfers: stop a transfer queued as the last one finishes from waiting for ever 2026-10-02 14:09:47 +02:00
jaap-jan 441709f1e9 Desktop: stop decrypting the whole keychain on a sync pass that changed nothing 2026-10-02 14:08:08 +02:00
jaap-jan 873405e591 Sync: mirror a pulled page in one write and read the outbox once per page, not twice per change 2026-10-02 14:04:36 +02:00
jaap-jan f997e87c5f SSH: open a shell off the caller's thread, and stop waiting when the caller gives up 2026-10-02 14:02:18 +02:00
jaap-jan 365954c541 Desktop: probe for the TPM once, off the UI thread, instead of on every unlock 2026-10-02 14:00:12 +02:00
jaap-jan 3ccafdd53b Desktop: drop the VAULT columns that had nothing in them, and the snippet buttons with nothing to act on 2026-10-02 13:58:07 +02:00
jaap-jan 22b4d82ba8 Desktop: stop the status bar's session timer when the shell ends 2026-10-02 13:54:24 +02:00
jaap-jan 619f49a35a Desktop: trim a long session tab label instead of letting it push the + off the strip 2026-10-02 13:54:24 +02:00
jaap-jan a6d3924e0d Desktop: call device unlock what this platform calls it, not Windows Hello everywhere 2026-10-02 13:52:00 +02:00
jaap-jan 079914bfb4 Desktop: let the arrows choose the host the drawer acts on, and Enter connect to it 2026-10-02 13:49:36 +02:00
jaap-jan 6c09bdecb6 Desktop: give the host-key, delete and sign-out questions the keyboard on their harmless answer, and let Escape say no 2026-10-02 13:45:40 +02:00
jaap-jan 372a7cbdc7 Desktop: put the keyboard in the closed vault's card on launch, and let Enter sign in and enrol 2026-10-02 13:39:12 +02:00
jaap-jan a4b81da597 Desktop: show the right vault's people, leave settings on lock, say when an import fell short, and stop host names being cut to four letters
ci / build and test (push) Successful in 3m26s
ci / android head (push) Successful in 3m50s
ci / desktop nightly (push) Failing after 37s
ci / api image (push) Successful in 17s
- 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.
2026-10-02 13:16:55 +02:00
jaap-jan 0e8fe4fc80 Give each profile its own device key, and make the Linux PIN window able to open at all
- 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.
2026-10-02 13:16:48 +02:00
jaap-jan 2ff9dff560 Terminal: deliver large pastes whole, keep keystrokes in order, and keep one session's failure off the shared socket
- 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.
2026-10-02 13:16:48 +02:00
jaap-jan 6846f67c20 Server: send each item at the revision its ciphertext is sealed at, keep team archiving with the owner, refuse suspended accounts, and stop enrolment failing after an hour
- 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.
2026-10-02 13:16:48 +02:00
jaap-jan f332d9a56f Stop a finished push deleting the edit made while it was in flight, and pull a newly synced type from the start
- 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.
2026-10-02 13:16:47 +02:00
jaap-jan 03c8e8589a Keep downloads inside the folder they were sent to, never run a file opened for editing, and let a bucket upload be retried
- 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.
2026-10-02 13:16:30 +02:00
jaap-jan 4c7f47c026 Refuse a pinned host that answers with a key of another type, rather than asking whether to trust it
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.
2026-10-02 13:16:30 +02:00
jaap-jan b08f2c58e3 Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address
ci / build and test (pull_request) Failing after 3m42s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 5m24s
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.
2026-09-29 10:34:38 +02:00
jaap-jan 38ab9b431b Sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
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.
2026-09-28 22:53:51 +02:00
jaap-jan 689ba6c1bf Merge origin/main into claude/spelunk-tunnel
Port forwarding took host schema version 8 first, so the Spelunk route moves to 9 and is written after the forwards. A host may carry both.
2026-09-28 21:38:09 +02:00
jaap-jan 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.
2026-09-28 21:25:04 +02:00
jaap-jan d7d002c972 Merge pull request 'Start port forwards on the phone, listed in a sheet off its terminal' (#20) from claude/dazzling-moser-641acd into main
ci / build and test (push) Successful in 3m57s
ci / desktop nightly (push) Successful in 1m28s
ci / api image (push) Failing after 13s
ci / android head (push) Successful in 5m32s
Reviewed-on: #20
2026-09-28 19:06:37 +00:00
jaap-jan c3dfa42108 Start port forwards on the phone, listed in a sheet off its terminal
ci / build and test (pull_request) Successful in 3m32s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 5m17s
ci / api image (pull_request) Failing after 13s
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.
2026-09-28 21:06:05 +02:00
jaap-jan a6a21decbd Report a host's pinned paths among the fields an edit changed
ci / build and test (push) Successful in 4m10s
ci / android head (push) Successful in 5m32s
ci / api image (push) Failing after 18s
ci / desktop nightly (push) Successful in 2m0s
2026-09-28 20:50:23 +02:00
jaap-jan f05deb4fbc Add port forwarding: local, remote and dynamic, per terminal session
ci / android head (push) Canceled after 4m6s
ci / desktop nightly (push) Canceled after 21s
ci / build and test (push) Successful in 3m43s
ci / api image (push) Canceled after 0s
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.
2026-09-28 20:47:02 +02:00