Compare commits

..
Author SHA1 Message Date
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 41f368ae75 Merge remote-tracking branch 'origin/main' into claude/audit-followups
ci / build and test (push) Successful in 3m45s
ci / android head (push) Successful in 4m15s
ci / desktop nightly (push) Failing after 43s
ci / api image (push) Successful in 46s
2026-10-02 15:27:39 +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 b1d4a270d4 Merge pull request 'Audit follow-ups: keyboard, wording, performance, transfer, sync, retry and relay fixes' (#22) from claude/audit-followups into main
ci / build and test (push) Failing after 3m10s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 4m7s
Reviewed-on: #22
2026-10-02 12:59:55 +00: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 965c9a7043 Server: stop a team being archived while a vault is created in it 2026-10-02 14:20:30 +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 cc3d46f78c Transfers: let cancel pressed as a transfer finishes do nothing rather than throw 2026-10-02 14:10:40 +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 412c314aec Merge pull request 'Fill in the Spelunk API key on the Security page, as a synced Spelunk key that goes only to its own address' (#21) from claude/spelunk-api-key-security-0dfad0 into main
ci / desktop nightly (push) Successful in 1m38s
ci / build and test (push) Successful in 4m32s
ci / android head (push) Successful in 4m54s
ci / api image (push) Successful in 24s
Reviewed-on: #21
2026-09-29 08:36:08 +00: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 ee423b25ce Merge claude/spelunk-sign-in: sign in to Spelunk servers through Spelunk, and connect straight away from a Spelunk link
ci / build and test (push) Successful in 3m38s
ci / android head (push) Successful in 3m54s
ci / api image (push) Successful in 7s
ci / desktop nightly (push) Successful in 1m36s
2026-09-29 09:16:12 +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 07bd5c9886 Merge claude/api-image-to-gitea-registry: push the API image to Gitea's own registry
ci / build and test (push) Successful in 4m2s
ci / android head (push) Successful in 5m27s
ci / api image (push) Successful in 18s
ci / desktop nightly (push) Successful in 1m50s
2026-09-28 22:07:41 +02:00
jaap-jan 31aca26e89 Push the API image to Gitea's own registry instead of registry-docker
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.
2026-09-28 22:07:06 +02:00
jaap-jan 843713b118 Merge claude/api-image-sees-illink-pin: the API image restores with the trimmer pin
ci / build and test (push) Successful in 6m15s
ci / android head (push) Successful in 6m19s
ci / desktop nightly (push) Successful in 1m19s
ci / api image (push) Successful in 40s
2026-09-28 21:44:56 +02:00
jaap-jan 9c7be8ae9b Let the API image see the trimmer pin, so its restore stops breaking on each .NET servicing release
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.
2026-09-28 21:44:56 +02:00
jaap-jan 3d0a66f997 Merge claude/spelunk-tunnel: reach a Spelunk-managed server through Spelunk, and open one from a Spelunk link
ci / desktop nightly (push) Canceled after 0s
ci / api image (push) Canceled after 0s
ci / android head (push) Canceled after 6m46s
ci / build and test (push) Canceled after 6m48s
2026-09-28 21:38:09 +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
jaap-jan c8848ec1e3 Send the event socket's close code instead of aborting the socket
ci / build and test (push) Successful in 4m22s
ci / android head (push) Successful in 5m16s
ci / api image (push) Failing after 52s
ci / desktop nightly (push) Successful in 4m12s
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.
2026-09-28 14:53:59 +02:00
jaap-jan ab432e475e Merge pull request 'Let a host marked for the relay actually be reached through it' (#19) from claude/server-relay-94b02f into main
ci / build and test (push) Successful in 8m4s
ci / api image (push) Canceled after 0s
ci / desktop nightly (push) Canceled after 36s
ci / android head (push) Canceled after 2m21s
Reviewed-on: #19
2026-09-28 12:41:29 +00:00
jaap-jan b20f7cae04 Let a host marked for the relay actually be reached through it
ci / build and test (pull_request) Successful in 6m27s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 15m47s
ci / api image (pull_request) Failing after 28s
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.
2026-09-28 14:37:20 +02:00
jaap-jan 54bc142dce Merge pull request 'Stop an attach's own replay landing in the middle of a test's frames' (#18) from claude/fix-failed-test-3e1719 into main
ci / build and test (push) Successful in 3m23s
ci / android head (push) Successful in 3m48s
ci / api image (push) Failing after 15s
ci / desktop nightly (push) Successful in 1m29s
Reviewed-on: #18
2026-09-15 12:46:46 +00:00
jaap-jan 8e61fc72b4 Stop an attach's own replay landing in the middle of a test's frames
ci / build and test (pull_request) Successful in 7m43s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Failing after 11s
ci / android head (pull_request) Successful in 8m7s
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.
2026-09-15 14:29:08 +02:00
jaap-jan 8a4058ce3a Merge pull request 'Stop a reconnect dialling a different machine of the same name' (#17) from claude/reconnect-wrong-server-9cccfb into main
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m24s
ci / build and test (push) Failing after 2m20s
Reviewed-on: #17
2026-09-14 11:31:04 +00:00
250 changed files with 27873 additions and 765 deletions

No files matched your search

+15 -5
View File
@@ -1326,6 +1326,19 @@ jobs:
# inspects — it is the thing that gets deployed. # inspects — it is the thing that gets deployed.
needs: [build] needs: [build]
runs-on: [linux] runs-on: [linux]
# Gitea's own container registry, on the forge this workflow runs on, under the same owner as
# the repository - so the image is listed with the repository's packages rather than on a
# registry of its own. Lowercase, because an image reference may not contain capitals and
# the owner is DodoTech-Public.
#
# REGISTRY_USERNAME and REGISTRY_PASSWORD are a Gitea account and a token of its own with
# package read and write, allowed to publish for that owner. Not the job's built-in
# GITEA_TOKEN, tempting as it is here: the container registry does not accept an Actions
# task token and answers `unauthorized` at /v2/ (go-gitea/gitea#23642). Spelunk's workflow
# pushes the same way, with the same two secrets.
env:
REGISTRY: git.dodotech.cloud
IMAGE: dodotech-public/dodossh-api
steps: steps:
# Duplicated from the build job; see the comment there for why it cannot be factored # Duplicated from the build job; see the comment there for why it cannot be factored
# out. Any change here has to be made in all three. # out. Any change here has to be made in all three.
@@ -1451,9 +1464,6 @@ jobs:
# changes, and where setup-buildx-action starts earning its place. # changes, and where setup-buildx-action starts earning its place.
- name: work out the tags - name: work out the tags
id: tags id: tags
env:
REGISTRY: registry-docker.dodotech.cloud
IMAGE: dodotech/dodossh-api
run: | run: |
set -euo pipefail set -euo pipefail
repo="$REGISTRY/$IMAGE" repo="$REGISTRY/$IMAGE"
@@ -1578,7 +1588,7 @@ jobs:
echo "username: ${#REGISTRY_USERNAME} characters; password: set" echo "username: ${#REGISTRY_USERNAME} characters; password: set"
printf '%s' "$REGISTRY_PASSWORD" \ printf '%s' "$REGISTRY_PASSWORD" \
| docker login registry-docker.dodotech.cloud \ | docker login "$REGISTRY" \
--username "$REGISTRY_USERNAME" --password-stdin --username "$REGISTRY_USERNAME" --password-stdin
- name: push - name: push
@@ -1596,7 +1606,7 @@ jobs:
# does not leave it behind. # does not leave it behind.
- name: log out - name: log out
if: always() && github.event_name != 'pull_request' if: always() && github.event_name != 'pull_request'
run: docker logout registry-docker.dodotech.cloud run: docker logout "$REGISTRY"
# No job here publishes the desktop *release* channel, and there is not going to be one. The desktop # No job here publishes the desktop *release* channel, and there is not going to be one. The desktop
# nightly job above publishes a different application — its own pack id, its own Velopack channel, its own # nightly job above publishes a different application — its own pack id, its own Velopack channel, its own
+114 -7
View File
@@ -44,7 +44,7 @@ Three consequences worth knowing before you read further:
The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with The reasoning behind each major decision is recorded in [`docs/adr/`](docs/adr/), starting with
[the E2EE trust model](docs/adr/0001-e2ee-trust-model.md). [the E2EE trust model](docs/adr/0001-e2ee-trust-model.md).
The desktop client's interface was built from a design covering more product than exists yet — file The desktop client's interface was built from a design covering more product than existed at the time — file
transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not transfer, teams, saved snippets, port forwarding. Everything that design asked for and this build has not
got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each got is written down in [`docs/design-import-gaps.md`](docs/design-import-gaps.md), with the layer each
piece would land in and what the interface shows in its place. Nothing was rendered with invented data to piece would land in and what the interface shows in its place. Nothing was rendered with invented data to
@@ -426,6 +426,90 @@ 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 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). [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
### Forwarding ports
A host can carry **port forwards**, added under PORT FORWARDS in its editor, in the three kinds OpenSSH has:
**local** (`-L`, a port here reaching a machine the remote can see), **remote** (`-R`, a port on the remote
reaching back to one this machine can see) and **dynamic** (`-D`, a SOCKS proxy here whose connections leave
from the remote). They are saved in the host's encrypted payload, so they sync, merge and are shared with the
host, and they start with every terminal opened to it, on either head. The session sidebar lists them under
FORWARDS with the state of each, and the status bar counts the ones listening; on the phone the same list is a
sheet off the terminal's bar.
Three things about them are worth expecting rather than discovering.
**Everything listens on localhost, and there is no setting for it.** A forward is a field any member of a
shared keychain can fill in, and one bound to every interface would hand the remote's network to anybody who
can reach your machine for as long as the terminal is open. A remote forward asks the remote for its loopback
too; whether the remote honours that is its own `GatewayPorts`.
**A forward lives as long as its terminal.** It runs over that tab's connection, so closing the tab, losing
the connection or reconnecting stops it — a reconnect starts the host's forwards again on the new session. A
second tab to the same host runs the same forwards, and they fail there because the ports are taken; the row
says so, and the terminal is unaffected. Any row can be switched off and on while its session is up.
**No byte counts**, which the design draws: SSH.NET keeps none, so a number there would be invented. The
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
[The Android head](#the-android-head).
### 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 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.
- **Fill in the API key once, under Settings → Security → Spelunk.** Give Spelunk's API address and paste
the key (`spelunk_…`, with the Admin role). It is saved as a *Spelunk key* in your personal vault —
encrypted, synced to your other devices, never shown again — together with the address, and **it is only
ever sent to that address**: a host that names the key with any other address is refused at connect. One
key serves every server in that Spelunk organisation; saving a key for an address that already has one
replaces it, and every host using it connects with the new key. The host editor offers saved keys first
and picks the one for the address you type. The API address must be `https://`, except for a Spelunk on
this machine.
- A key kept the older way, as a credential's password, still works and is still offered in the editor;
its password goes to whatever address the host names.
- **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.
**Sign in through Spelunk (no SSH key needed)**, the box under the key in the same card, lets Spelunk log
you in instead of an SSH key or password of the host's own. For each connection DodoSSH makes a throwaway
Ed25519 key pair in memory and sends the public half with the ticket request, which the API key authorises;
the agent runs a one-connection `sshd -i` on the server whose only authorised key is that one, and says
which account it logs in as (`root`). It is real SSH to the server's real host key, so shells, file
transfers and port forwards all work, and the key is gone with the connection — it is never stored,
synced or logged. The login and authentication choices are greyed out while it is ticked and saved as
nothing. The server needs OpenSSH's `sshd` installed (not running, and no open port); an agent too old
to sign in, or a server without `sshd`, is said so. Such a host is written at host schema version 10,
so a DodoSSH older than this shows it read-only.
**"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, and **connects straight away.** The link never
carries a credential. The hosts links make live in your personal vault, tagged `spelunk`:
- If your personal vault has a host tagged `spelunk` for that server on that Spelunk, DodoSSH connects to it.
- Otherwise, when you have saved a Spelunk key for that Spelunk under Settings → Security — or a saved host
already sends an API key there — DodoSSH saves a new host in your personal vault — named as the link
names it, at the link's address (or `spelunk-<server id>`), port 22, signing in through Spelunk with that
key, tagged `spelunk` (the tag is made if the vault has none) — and connects to it. It syncs like any host
you saved yourself.
- **A link from a Spelunk with no key** opens the host editor instead, filled in the same way with sign-in
ticked and the tag on, and you choose that Spelunk's API key once and save. Every later link from that
Spelunk connects straight away. A link can be sent by anything on this machine, so an API key is never
sent to an address you have not sent it to — saving the key under Security is how you say you have.
A host you set up by hand, or one in a team's vault, is left as it is: a link for its server adds a host of
its own. 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 ### Sharing a vault
**VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one, **VAULTS** in the nav rail lists every vault you can see, makes new ones, adds people to one,
@@ -641,8 +725,9 @@ its host if it has one and back into the box if it does not.
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom **A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the
sessions as pills, and a `+` on the right offering the three connections this application can make — a sessions as pills, and a `+` on the right offering the three connections this application can make — a
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄`
that menu first if it is open. opens them; see below. The system back gesture does what the arrow does, and lowers either sheet first if
one is open.
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
screen exactly the width it was designed against. screen exactly the width it was designed against.
@@ -710,10 +795,25 @@ What it does *not* yet do is use the extra width inside a screen: the host list
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
heading has to span — which is shared with the desktop and is its own piece of work. heading has to span — which is shared with the desktop and is its own piece of work.
**Port forwarding is not built anywhere**, and the phone's More screen says so in a paragraph rather than **Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every
leaving a gap. The v2 design draws a whole screen for it; nothing in the SSH layer forwards anything, so terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has
every control on that screen would have had no effect. See any, carrying the number listening in green, or turning red when one could not start; it opens a sheet
[`docs/design-import-gaps.md`](docs/design-import-gaps.md). listing the tab's forwards with their route, their state, the reason a failed one gave, and a tap on each to
switch it off or on — the desktop sidebar's FORWARDS section, over the same rows. The v2 design's whole
FORWARDING screen is not drawn, for the reason the desktop has none: a forward belongs to a host and runs
with a session, and both already have a place. What the phone has not got is an editor for them — its host
form has no PORT FORWARDS section — so they are added on the desktop and arrive with the host; the More
screen says so in a paragraph.
Forwards ride the **keep-alive** a shell already has. A forward runs over its tab's connection, so the
foreground service that holds a shell open in the background is holding its forwards open too, and nothing
else needs to: the service is never started for a forward alone, because there is never a listening forward
without a live shell. What forwards change is the notification, which counts them beside the shells —
`1 shell connected · 2 ports forwarded` — because a phone in a pocket holding ports open is something its
owner should be able to see. Local and SOCKS forwards listen on the phone's own loopback, which every app on
the phone can reach; that is what makes one useful here — a database client on the phone reaching a server
behind the remote — and it is the same exposure the desktop's localhost has to its own processes. See
[Forwarding ports](#forwarding-ports) and [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which Android SDK a prerequisite of `dotnet build DodoSSH.slnx` for everybody; it has its own CI job instead, which
@@ -873,6 +973,13 @@ keychain plus a terminal — and the spike that gates all of it.
[Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which [Moving files](#moving-files) for the two things about it worth knowing before you use it, both of which
are consequences rather than choices. are consequences rather than choices.
*Relay done:* a host with "Connect through the server relay" ticked is reached through the deployment,
for a machine this one cannot dial. The server issues a single-use, thirty-second ticket for that host
alone, resolves its address and checks it against a deny list with no configuration escape — loopback,
link-local, metadata endpoints — and then pipes raw TCP; the SSH session still ends on your machine, so
the relay sees ciphertext. It is off by default (`Relay:Enabled`), and it only helps when the server can
reach what you cannot. See [ADR 0004](docs/adr/0004-relay-authorization.md).
*Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary *Organising done:* hosts can be filed into groups, and commands can be saved as snippets. Both are ordinary
synced items — encrypted, merged and pushed like every other — and both are invisible until used: a synced items — encrypted, merged and pushed like every other — and both are invisible until used: a
keychain with no groups draws the flat host list it always did. Two things about them are deliberate. keychain with no groups draws the flat host list it always did. Two things about them are deliberate.
+9 -1
View File
@@ -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 # 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 # name deliberately and not the package's binary name — the window does not know which package
# started it. # 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 mkdir -p %{buildroot}%{_datadir}/applications
cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP cat > %{buildroot}%{_datadir}/applications/%{dodo_bin}.desktop <<DESKTOP
[Desktop Entry] [Desktop Entry]
@@ -255,13 +262,14 @@ Version=1.0
Name=%{dodo_title} Name=%{dodo_title}
GenericName=SSH Client GenericName=SSH Client
Comment=SSH and SFTP with an end-to-end encrypted credential vault Comment=SSH and SFTP with an end-to-end encrypted credential vault
Exec=%{dodo_bin} Exec=%{dodo_bin} %%u
Icon=%{dodo_bin} Icon=%{dodo_bin}
Terminal=false Terminal=false
StartupNotify=true StartupNotify=true
StartupWMClass=DodoSSH StartupWMClass=DodoSSH
Categories=Network;RemoteAccess;System; Categories=Network;RemoteAccess;System;
Keywords=SSH;SFTP;Terminal;Remote;Shell; Keywords=SSH;SFTP;Terminal;Remote;Shell;
MimeType=x-scheme-handler/dodossh;
DESKTOP DESKTOP
# One file per size into the hicolor theme, named for the package so the two channels show different # One file per size into the hicolor theme, named for the package so the two channels show different
+19
View File
@@ -119,6 +119,25 @@
<key>LSUIElement</key> <key>LSUIElement</key>
<false/> <false/>
<!--
The dodossh:// scheme, which is how Spelunk's "Open in DodoSSH" reaches the application. macOS
does not pass the link as an argument: Launch Services sends an "open URL" event to the running
application, or to the one it has just started, and DodoSshApp.ListenForOpenedLinks takes it from
Avalonia's activation event. Registered when the bundle is first seen by Launch Services, which
for a Velopack install is on first launch from /Applications.
-->
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>dev.dodotech.dodossh.link</string>
<key>CFBundleURLSchemes</key>
<array>
<string>dodossh</string>
</array>
</dict>
</array>
<key>NSHumanReadableCopyright</key> <key>NSHumanReadableCopyright</key>
<string>© DodoTech. MIT licensed.</string> <string>© DodoTech. MIT licensed.</string>
</dict> </dict>
+9 -1
View File
@@ -68,7 +68,15 @@ the Npgsql connection string** — the default; do not enable multiplexing.
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.** visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be - Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
able to read tombstones, which is the one place that legitimately bypasses the soft-delete able to read tombstones, which is the one place that legitimately bypasses the soft-delete
query filter — guarded by an explicit permission check. query filter — guarded by an explicit permission check. **The collection has since been built**
(`TombstoneCollector`), and it costs a machine away for longer than the retention its news of
those deletions. So a collection raises the vault's sync horizon, and a cursor from before it is
refused. The client then reads from the beginning, and when that read finishes it removes the
items it held that the read never mentioned. An item with an unsent local edit is kept: the
push finds the server without it, puts it back, and records a `RecreatedAfterCollection`
conflict. A cursor carries the horizon it was issued under. Without that, the read from the
beginning would be refused on its own early pages, which sit behind the horizon whenever an
older item survives.
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.** - **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
Without it this ADR's central bug is invisible until production. Without it this ADR's central bug is invisible until production.
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic - Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
+19
View File
@@ -99,3 +99,22 @@ real ones.
- **Client-supplied target address.** One line of convenience, and the relay becomes an open - **Client-supplied target address.** One line of convenience, and the relay becomes an open
proxy into the operator's network. proxy into the operator's network.
- **Allow-listing by hostname.** Defeated by DNS rebinding. - **Allow-listing by hostname.** Defeated by DNS rebinding.
## Implementation notes
Built in `DodoSSH.Api/Features/Relay` and, on the client, `RelayBridge` and `ServerRelay`; see
`docs/reaching-a-host-you-cannot-dial.md`, "What step 3 built". Three places differ from the text above:
- **Backpressure is an awaited copy loop, not `System.IO.Pipelines`.** Each direction reads one buffer and
writes it before reading again. The property is the same — a slow peer stops the read, and TCP throttles
the origin — and the bound is tighter: two 32 KiB buffers per session.
- **There is no `?ticket=` fallback.** Both clients can set a subprotocol, and the fallback's cost — a bearer
credential in the URL — was the reason it needed scrubbing at all. It can be added if a constrained
client ever needs it; the scrubbing requirement stands.
- **The ticket carries no device id.** The server does not see a device on an ordinary request, so there was
nothing to put there. `PortForwardId` on the request is refused: a forward's port is ciphertext, and
honouring one would mean taking the port from the client.
The loopback bridge serves the relay only. SSH.NET's `ForwardedPortDynamic` already is the jump-host path's
listener, so "one mechanism, two features" is true one level down: both features reach their target through
a loopback SOCKS5 proxy named on the connection request.
+2 -1
View File
@@ -631,7 +631,8 @@ go at 360dp:
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together, for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
because it is shared. What v2 asked for and did not get is in because it is shared. What v2 asked for and did not get is in
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is [`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
named as absent rather than drawn, because nothing in the SSH layer forwards anything. named rather than drawn as a screen: it has since been built, and this head runs a host's forwards with
every terminal and lists them in a sheet off the terminal's bar. It cannot edit them yet.
**The bar has since gone from four entries to three.** The keychain joined the hub, which is now called **The bar has since gone from four entries to three.** The keychain joined the hub, which is now called
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
+5 -1
View File
@@ -299,7 +299,8 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
`1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup, `1` User, `2` Device, `3` Vault, `4` Host, `5` Credential, `6` SshKey, `7` HostGroup,
`8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag, `8` Tag, `9` Snippet, `10` PortForward, `11` KnownHostKey, `12` HostTag,
`13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore. `13` HostCredential, `14` ConnectionLogEntry, `15` ActivityLogEntry, `16` ObjectStore,
`17` SpelunkKey.
> **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and > **Added 2026-07-29:** `12` and `13`. `Contracts.SyncEntityType` has listed `HostTag` and
> `HostCredential` as syncable since the contract was frozen, but this table had no value for > `HostCredential` as syncable since the contract was frozen, but this table had no value for
@@ -318,6 +319,9 @@ value can forge a field boundary. UUIDs must be serialised in RFC 4122 order —
> `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and > `ConnectionLogEntry` is `11` there and `14` here. Nothing casts between them, and
> `AadResourceTypeTests` pins each pairing by name for exactly that reason. > `AadResourceTypeTests` pins each pairing by name for exactly that reason.
> **Added 2026-09-29:** `17` SpelunkKey, a Spelunk API key and the address it may be sent to. It is
> `14` on the wire, three behind, as `16` ObjectStore is `13`.
`0` means not applicable and is legal only where the table in §4.2 implies no resource. `0` means not applicable and is legal only where the table in §4.2 implies no resource.
### 4.4 What this prevents ### 4.4 What this prevents
+16 -13
View File
@@ -39,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> >
> | v2 element | What ships instead | > | v2 element | What ships instead |
> | --- | --- | > | --- | --- |
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Nothing, said out loud.** `ISshConnection` offers `OpenShellAsync` and nothing else, so there is no tunnel for a rule to run through; `SyncEntityType.PortForward = 9` is still reserved and still unused. The MORE screen carries a paragraph naming the absence, for the reason the desktop keeps VAULTS in its rail. | > | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Built, as a sheet off the terminal rather than a screen, and running on the phone as on the desktop.** A host's forwards start with every terminal opened to it on either head — `MainWindowViewModel.StartForwards` no longer skips Android. The terminal's bar draws a `⇄` for a tab that has forwards, with the listening count in green and the glyph in the danger colour when one failed, and it raises a sheet (`IsForwardsSheetOpen`) listing `SelectedTab.Forwards` with notation, route, state word, failure reason and a tap-to-toggle per row — the desktop sidebar's FORWARDS section over the same rows. Not a screen, for the desktop's reason: a forward belongs to a host and runs with a session. **Two gaps remain.** The phone's host form has no PORT FORWARDS section, so forwards are edited on the desktop and arrive with the host; the MORE screen's paragraph, now tagged IN THE TERMINAL, says so. And byte counts are built on neither head: SSH.NET keeps none. The keep-alive notification counts listening forwards beside the shells they run over, and is never started for a forward alone, since a forward cannot outlive its shell. |
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. | > | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwarded anything at the time (forwards now have the `⇄` in the bar above; see the row before this one), so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. | > | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. | > | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. | > | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
@@ -78,7 +78,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> >
> | v2 element | What ships instead | > | v2 element | What ships instead |
> | --- | --- | > | --- | --- |
> | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | Nothing, as on the phone and for the same reason. Three surfaces for a feature the SSH layer does not have. | > | The **FORWARDING** screen, the `⇄ N forwards` chip on the session strip, and the host detail's FORWARDING card | **Built, as three smaller things.** Forwards are rules on the host, edited in its drawer under PORT FORWARDS and shown read-only on its detail pane; they run per terminal, listed with a switch each under FORWARDS in the session sidebar; and the session status bar counts the listening ones as `⇄ N FORWARDS`. No screen of their own, because a forward is not a thing you visit — it belongs to a host and runs with a session, and both of those already have a place. |
> | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn as a grid. There is no fingerprint on a stored key and no last-used timestamp at any layer, so two of the three cards have nothing behind them. Tags are real and are drawn — as chips on the row and a picker in the editor, which is where they are used rather than in a card beside two empty ones. | > | The host detail's 2×2 card grid — IDENTITY fingerprint, TAGS, LAST SESSION | Not drawn as a grid. There is no fingerprint on a stored key and no last-used timestamp at any layer, so two of the three cards have nothing behind them. Tags are real and are drawn — as chips on the row and a picker in the editor, which is where they are used rather than in a card beside two empty ones. |
> | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. | > | The keychain detail's public-key block, USED BY hosts, and **Rotate** | The detail pane as it stands. Copy public key is real and already there; rotation is not a thing this client can do. |
> | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. | > | Logs: **FOLLOW**, the severity chips, and `tailing via ssh · journald + files` | The two logs it really has. Same as the phone — these are synced audit records, not a tail, and the footer sentence describes a feature rather than the screen. |
@@ -171,7 +171,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. | > | The **Backspace / Default** row | Omitted, and it is the one row on this list that could now be built cheaply — a setting, an opcode and a control, the way the text size was. What it lacks is a reason: which byte backspace sends is a fact about the remote's `stty`, so a client-side switch fixes a mismatch by hiding it. See the Preferences section. |
> | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. | > | The **chevron beside the vault name** | The name alone, and the move behind the pane's ⋯ menu instead. A host *can* now be moved between vaults, so the gap is no longer that there is nothing to offer — it is that a chevron on a subtitle implies an edit, and this is not one: the two vaults are encrypted under different keys, so it is a re-seal into one and a tombstone in the other, the host takes a new id, and its group and tags stay behind. A control that implied "just change this field" would be describing something else. Where a *new* host goes is still asked in the host editor, as a picker beside the name. A group moves too, from its card's right-click menu, and takes its nested groups and every host filed under them; keys, passwords and buckets take theirs from the keychain screen's standing picker and cannot be moved yet. |
> | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. | > | **Show more ⌄** | Not drawn as a disclosure. What it would hide — notes, the relay switch, forgetting the host key — is in the editor, one press away, and a second fold inside a pane that already scrolls is a second place for a field to be missing from. |
> | **Port Forwarding** in the sidebar | Nothing, for the third time in this document. | > | **Port Forwarding** in the sidebar | No rail entry; see the FORWARDING row above for where forwards are instead. |
> | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. | > | The host grid's toolbar avatar, share and tag-filter controls | Omitted, as in v3 and for the same reasons. |
> >
> ## The desktop's v5 — hosts, restyled, and two reversals > ## The desktop's v5 — hosts, restyled, and two reversals
@@ -254,7 +254,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | v5 element | What ships instead | > | v5 element | What ships instead |
> | --- | --- | > | --- | --- |
> | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. | > | The status dot's third, amber state | ◆ **Still two states, on the same reasoning v3 and v4 both gave and v5 was asked to reconsider.** Green means a terminal is open on that host, grey means one is not; nothing here pings a machine, so the amber "reachable but not connected" state has no fact behind it to draw. |
> | The relay checkbox's mock copy | The pre-existing sentence, unchanged, "(not wired up yet)" included — restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it) but not reworded. The mock's own copy implies a relay this client can dial; it cannot, for the reason `HostDrawer.axaml`'s own remarks give. | > | The relay checkbox's mock copy | Restyled into the mock's own nested-card shape (radius 12, a title beside the box rather than under it). The "(not wired up yet)" it carried while this client could not dial the relay came out when it could; the sentence under it now says what ticking it does — the server dials the host, so the server has to be able to reach it, and the address is stored in plain text. |
> | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". | > | The host card as a link straight to a terminal | Click still selects, double-click still connects, and the pencil still opens the pane — the mock's card-as-link is not adopted, because multi-select (Ctrl, Shift, the marquee band) depends on a plain click meaning "choose this one" rather than "go". |
> | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. | > | Quick connect's SSH/SFTP kind column | The auth word — credential, key, or password — see above. |
> | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. | > | A collapsed section staying collapsed after a restart | In memory only, for the running session. `settings.json` holds two scalars by decision — the terminal's text size and whether this machine checks for updates on its own — and collapse state is not judged worth a third. |
@@ -295,7 +295,7 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
> | v5b element | What ships instead | > | v5b element | What ships instead |
> | --- | --- | > | --- | --- |
> | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. | > | The host header's OS label (`Ubuntu 24.04 LTS`) and latency reading (`12 ms`) | Neither. The client does not know the remote's OS and nothing measures round-trip time — the same two absences the terminal pane header already recorded before this pass, carried into the new header rather than reopened. |
> | A **Port forward** button on the host header | Omitted. The feature does not exist; see the FORWARDING rows earlier in this document. | > | A **Port forward** button on the host header | **Add forward**, at the foot of the session sidebar's FORWARDS list, which opens the host's editor the way Pin folder does. The header it would have sat on has since moved into that sidebar. |
> | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". | > | The tab and status dots' third, amber state | ◆ **Still two states**, for the reason the hosts screen's own dot has stayed two states since v3: green means a shell is open (or a session is connected), grey means it is not, and nothing here pings a host to justify a third colour meaning "reachable but not connected". |
> | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. | > | The status bar's negotiated cipher, host-key algorithm and key/credential name | ◆ **Shipped, on both surfaces, with three honest deviations.** `ISshConnection` and `ISftpSession` now both carry `Cipher` — the server-to-client algorithm off SSH.NET's own `ConnectionInfo.CurrentServerEncryption`, captured once at construction because a rekey is not an event SSH.NET raises — and `TerminalWorkspace.GetSessionFacts` hands the cipher and the host key's algorithm back to the shell the moment a session opens; `VaultViewModel.TryBuildAuthentication` now threads the authenticating key's or credential's own `Label` into `HostAuthentication.IdentityLabel`, all the way to `MainWindowViewModel`'s surface-aware `SessionCipher`, `SessionHostKeyAlgorithm` and `SessionIdentityLabel`, composed into one `SessionIdentityText` run for the status bar. Three deviations from the mock, not omissions: the algorithm prints exactly as negotiated (`ssh-ed25519`), not the design's shortened `ed25519`, because trimming it would be an edit to a string this client did not choose; the run is plain text rather than the design's clickable element, because there is no pin-details modal for a session that is already open, and drawing a click target for a screen that does not exist would itself be a fabrication; and a typed-password session — nothing filed in the keychain to name — shows the host-key algorithm alone, with no `·` after it, because there is no item behind the dot. |
> | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. | > | S3 dimmed in the design's own switcher | **Enabled.** The mock leaves S3 as future work; this application already has bucket browsing, so SSH, SFTP and S3 are a true three-way segment, wired to `IsSshShowing`, `IsTransfersShowing` and `IsBucketsShowing` exactly alike. |
@@ -392,9 +392,10 @@ sharing that would rest on them — or a decision recorded here not to build the
Three facts explain nearly every row below. Three facts explain nearly every row below.
**An SSH connection here opens exactly one channel.** `ISshConnection` offers `OpenShellAsync` and nothing **An SSH connection here opens a shell, and forwards beside it.** `ISshConnection` offers `OpenShellAsync`
else (`src/DodoSSH.Client.Ssh/SshConnection.cs`). No port forwarding, no ProxyJump. That one fact removes and `StartForwardAsync` (`src/DodoSSH.Client.Ssh/SshConnection.cs`). There is still no ProxyJump, which is
the `FORWARDS` chip, the status bar's port list, and every `via bastion-eu` in the design. what removes every `via bastion-eu` in the design. Port forwarding arrived later than most of what this
document accounts for, and the rows about it now say where it went.
It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it: It used to remove the file-transfer screen too. M2 did not lift the restriction — it worked around it:
file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns file transfer is a *separate connection* rather than a second channel, because SSH.NET's `SftpClient` owns
@@ -461,8 +462,10 @@ configuration, a server kind that refuses every plaintext field, a codec, a merg
same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a same way, for the same cost, and is the clearest case the reservation ever paid for: a whole item kind, a
table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched. table, a migration and an AAD arm, with `PublicAPI.Unshipped.txt` untouched.
`HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` is now `HostTag`, `HostCredential` and `PortForward` are still reserved and still unused — and `HostTag` and
reserved *against a decision* rather than pending one. Which hosts wear a tag is a set of ids inside each `PortForward` are now reserved *against a decision* rather than pending one. Port forwards shipped inside the
host's payload, for the reason `RelayTicketRequest.PortForwardId` already assumed and the one below gives for
tags. Which hosts wear a tag is a set of ids inside each
host's payload, not a join: the one thing a join buys over that is two machines tagging the same host host's payload, not a join: the one thing a join buys over that is two machines tagging the same host
without either losing, and the client's per-key three-way merge already gives it. A second item type would without either losing, and the client's per-key three-way merge already gives it. A second item type would
be a second table and a second pass through every rule in this file, for nothing. be a second table and a second pass through every rule in this file, for nothing.
@@ -509,7 +512,7 @@ field cannot be removed and stays as a permanently refused member; `SyncEndpoint
| `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. | | `VAULT SYNCED 11:02` | client-session | `StoredSyncState.LastPulledAt`/`LastPushedAt` are persisted, but `VaultSession` exposes the store as `internal`. A property away. | Omitted. The one honest sync fact — the outbox depth — is in the titlebar and the status bar. |
| `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". | | `⌘K` command palette running commands | client-domain | A snippet or saved-command item type (`SyncEntityType.Snippet = 8` is reserved). | Ctrl+K opens a real host search that connects on Enter. The box says "search hosts", not "search hosts · run command". |
| Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. | | Status bar `· via bastion-eu` | client-ssh | Jump-host execution. See below. | Omitted. |
| Status bar port forwards | client-ssh | Port forwarding. See below. | Omitted. | | Status bar port forwards | client-ssh | — | **Shipped** on the session status bar as a count of listening forwards; the ports themselves are in the session sidebar. |
| Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. | | Status bar `sftp · 2 transfers` | client-app | Nothing now — file transfer is built. What is missing is the count reaching the status bar, which is a screen away from where the queue lives. | Omitted from the status bar. The queue itself is on the FILES screen, with a row per transfer. |
| Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. | | Status bar `locks in 09:41` | client-app | An idle auto-lock. See preferences below. | Omitted. |
| IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. | | IBM Plex Mono / IBM Plex Sans | ui | Shipping the font files as `AvaloniaResource` and registering them. The design loads them from Google Fonts, which a desktop app cannot. | **Shipped, since v5, and not IBM Plex.** The v5 design asked for Montserrat, JetBrains Mono and Material Icons instead, and all three are embedded as `AvaloniaResource` in the Shell project rather than requested by name and left to whatever the machine happens to have. Montserrat is the default sans, with Inter — the earlier substitution — kept only as its fallback; `MonoFont` puts JetBrains Mono first, ahead of the system-monospace stack the terminal still falls back to. Named once each in `Program.cs` and `Palette.axaml`, so a later design's fonts are still a one-line swap. |
@@ -533,7 +536,7 @@ caption buttons and window title drawn on top of the application's own — two s
| Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. | | Per-host status dot, three colours | client-ssh | The amber state would mean "reachable but not connected", and nothing here ever probes a host. | Two states, both real: green when a terminal is open on that host, grey when not. |
| `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. | | `· ⤷ bastion-eu` in the host subtitle | client-ssh | **Jump hosts are data-only.** `HostSecret.JumpHostIds` is a `JumpChain` that is stored, encrypted, synced and three-way merged — and nothing reads it at connect time. `SshConnectionRequest` carries one host. | Omitted. The stored chain is preserved untouched by every edit. |
| `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. | | `SPLIT ⌘D` and side-by-side panes | client-ssh + ui | The renderer stacks panes and shows one (`terminal.css`: `.pane { position:absolute; inset:0; display:none }`). Tiling needs a real pane geometry and a splitter. | Omitted. Tabs ship instead, over the same one-WebView multiplexing. |
| `⇄ FORWARDS · 2` | client-ssh | Port forwarding. `SyncEntityType.PortForward = 9` is reserved; nothing in the SSH layer forwards anything. | Omitted. | | `⇄ FORWARDS · 2` | client-ssh | — | **Shipped**, as `⇄ 2 FORWARDS` on the session status bar, counting only forwards actually listening. |
| `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. | | `SNIPPETS` panel, `↵` to run | client-domain | A snippet item type (`Snippet = 8`, reserved). | **Shipped**, as a screen rather than a panel. `↵` is per snippet and off by default: inserting types the command at the prompt and stops, because nothing here can tell whether the terminal is at a prompt at all. |
| Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. | | Broadcast to all panes (`⌥↵`) | client-ssh | Input is routed strictly by session id in `TerminalDataPlane.Dispatch`; there is no fan-out. Needs splits first. | Omitted. |
| Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. | | Pane header `24ms` | client-ssh | Round-trip measurement. SSH.NET offers no RTT API. | Omitted. |
+71 -10
View File
@@ -1,26 +1,30 @@
# Reaching a host you cannot dial # Reaching a host you cannot dial
Some machines do not answer from where the user is sitting. This product has **two** answers to that, and Some machines do not answer from where the user is sitting. This product has **two** answers to that, and
neither of them works. one of them works.
- The **relay** pipes raw TCP through the deployment. The server half is built and shipped; the client - The **relay** pipes raw TCP through the deployment. Both halves are built: the server issues a ticket and
half does not exist, and both heads offer a checkbox that promises it. pipes a WebSocket to the host, and the client reaches it through a loopback SOCKS5 bridge. Step 3 below.
- A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds` - A **jump host** reaches the target through a machine already in the keychain. `HostSecret.JumpHostIds`
stores the chain; nothing writes it and nothing reads it. stores the chain; nothing writes it and nothing reads it.
◆ **An earlier version of this page said the relay's server half was "built and shipped". It was not.** What
existed was ADR 0004, the contract records, the options and the CHECK constraint — and `RelayOptions` said,
in its own remarks, that there was no endpoint and no dialler. Both halves were built together at step 3.
This document is the comparison between them, which is the thing that has to be settled before either is This document is the comparison between them, which is the thing that has to be settled before either is
built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the built, and then the plan. It replaces an earlier draft of `docs/jump-hosts.md` that recommended deleting the
jump chain — see the last section for why that was wrong. jump chain — see the last section for why that was wrong.
> **Status: step 0 done, the rest planned.** > **Status: steps 0, 1 and 3 done, and step 4 for the relay. Jump hosts are next.**
> >
> | Step | State | Notes | > | Step | State | Notes |
> | --- | --- | --- | > | --- | --- | --- |
> | 0. Stop promising the relay | **Done** | Both heads say the box is not wired up and that ticking it stores the address and changes nothing. Left tickable, so a host already carrying the flag can lose it | > | 0. Stop promising the relay | **Done, then retired at step 3** | The "not wired up yet" sentence came out of both heads when the relay started working |
> | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below | > | 1. Dial through a loopback proxy | **Done** | `SshLoopbackProxy` on the request, honoured in `BuildConnectionInfo`. **Not the bridge** — see below |
> | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy | > | 2. Jump hosts over it | Not started | No server change, and no bridge either: SSH.NET's own forward is the proxy |
> | 3. The relay: the bridge, then the ticket | Not started | The bridge's only consumer, so it lands with the feature that uses it | > | 3. The relay: the bridge, then the ticket | **Done** | Server: `Features/Relay`. Client: `RelayBridge`, `ServerRelay`, and `VaultViewModel.OpenThroughAsync`. See "What step 3 built" |
> | 4. File transfer parity | Not started | The transfers screen opens its own connection. Already half-done — `OpenSftpAsync` shares `BuildConnectionInfo`, so it honours a proxy today; what is missing is anything passing it one | > | 4. File transfer parity | **Done for the relay** | `TransfersViewModel` opens a relay for a relayed host exactly as the shell does. A jump chain will need the same line when step 2 lands |
## ◆ The relay's checkbox is a false promise, and that is a defect ## ◆ The relay's checkbox is a false promise, and that is a defect
@@ -171,7 +175,64 @@ the sentence step 0 added comes out of both heads.
**4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that **4. File transfer.** `ISftpSessionFactory.OpenSftpAsync` opens its own second connection, so a host that
needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this needs a chain or a relay to reach needs it there too, or SFTP silently fails for exactly the hosts this
work exists for. work exists for. ✅ for the relay.
## What step 3 built
**The server**, in `DodoSSH.Api/Features/Relay`:
- `POST /api/v1/relay/tickets` makes every decision. It finds the host, requires Connect on its vault and
the relay switched on, resolves the stored name, and runs every answer through `RelayAddressPolicy` —
the non-overridable list first, then the operator's denials, allow-list and private-network switch. What
passes goes into a Data Protection ticket; the name is not resolved again. **Every target refusal is one
answer**, 403 `relay-target-rejected` with one sentence, because anybody who can write a host can point
it anywhere, and a distinct "resolves, but refused" would be a scanner for the operator's DNS. The real
reason is logged (event 2301).
- `GET /api/v1/relay/connect` is anonymous at the HTTP layer and takes the ticket as the second
subprotocol. It verifies, checks expiry against `TimeProvider`, and spends the ticket with a unique
insert into `relay_ticket_use` before accepting the upgrade — so a refused ticket is a 401 and no socket.
After the upgrade, a refusal the client could not have avoided is a close code: 4429 for a limit, 4502
for a host the server could not reach, 4408 idle, 4409 lifetime, 1001 at the end of a shutdown's drain.
- `relay_session` is the audit row: opened before the dial, closed after, each from a scope of its own.
**The client:**
- `RelayBridge` (Client.Ssh) is the loopback SOCKS5 listener. It binds `127.0.0.1`, accepts once, answers
one CONNECT without acting on its target, and pipes to the WebSocket. It is handed to the connection as
the new `SshLoopbackProxy.Lifetime`, so the connection disposes it on every way a session ends, and a
failed connect disposes it before throwing.
- `ServerRelay` (Client.Session) asks for the ticket, opens the socket and starts the bridge, and turns
every refusal into a sentence. It is `IVaultServer.Relay`, decided from `/api/v1/meta` the way `Events`
is; a server without the feature supplies `UnavailableServerRelay`.
- `VaultViewModel` opens a relay for a host with the box ticked and **never falls back to dialling it
directly** — the user ticked it because that path does not work, or goes somewhere they do not mean. A
relay that closed with a reason replaces SSH.NET's transport error with that reason, and the
reachability probe is skipped for a relayed host, because it would test the direct path.
### ◆ Two things that are not what ADR 0004 wrote
- **No `System.IO.Pipelines`.** Each direction reads one buffer and does not read again until the write
has completed. That is the same end-to-end backpressure — a slow peer stops the read, which fills the
kernel's window, which throttles the far sender — with a tighter bound: two 32 KiB buffers a session
rather than ~2 MiB.
- **No `?ticket=` fallback, and no device id in the ticket.** The fallback exists for clients that cannot
set a subprotocol, which neither head is, and it would put a bearer credential in the one place — the
URL — that access logs keep. A device is never visible to the server on an ordinary request.
### ◆ A WebSocket operation is never cancelled, on either side
Cancelling a pending receive or send on a .NET WebSocket aborts it, and an aborted socket sends no close
frame. The close frame is the only way the client learns that the server could not reach the host, so both
the server's pump and the client's bridge stop a direction by closing and waiting a bounded time instead.
`AnIdleSession_IsClosedWithTheIdleCode` holds the server to it: it asserts the code arrives, not merely that
the socket ended.
### What a developer meets
The relay refuses loopback with no configuration escape, so the development stack's sshd on
`localhost:2222` cannot be reached through it. Point a relayed host at an address the API process reaches
on a real interface — the machine's own LAN address works — or at the container's address on the Docker
network.
## Traps already known ## Traps already known
@@ -226,8 +287,8 @@ is right, sharing a lifetime policy is not.
row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only. row, and the status bar's `via bastion-eu` row, all of which say jump hosts are data-only.
- `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the - `Client.Import/ImportedHost.cs` — the note written onto every imported host with a `ProxyJump`, and the
remark above it. remark above it.
- `README.md` and `docs/android-port.md` wherever the relay is described as available. - `README.md` and `docs/android-port.md` wherever the relay is described as available. ✅ at step 3.
- ADR 0004 gains a note that its last consequence was built, and how. - ADR 0004 gains a note that its last consequence was built, and how. ✅ at step 3.
## What the first draft of this document got wrong ## What the first draft of this document got wrong
@@ -0,0 +1,49 @@
using DodoSSH.Domain;
using Microsoft.AspNetCore.Authorization;
namespace DodoSSH.Api.Authorization;
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
/// <remarks>
/// <para>
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
/// that no endpoint can be reached without it.
/// </para>
/// <para>
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
/// reached by this; it ends on its own idle and lifetime limits.
/// </para>
/// </remarks>
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
: AuthorizationHandler<ActiveAccountRequirement>
{
/// <inheritdoc />
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
ActiveAccountRequirement requirement)
{
ArgumentNullException.ThrowIfNull(context);
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
// as well would turn that into a 403.
if (context.User.Identity?.IsAuthenticated != true)
{
return;
}
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
if (user.Status == UserStatus.Active)
{
context.Succeed(requirement);
}
}
}
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping /// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
/// requests from one user would each read the row, each set the timestamp, and the second would /// requests from one user would each read the row, each set the timestamp, and the second would
/// fail on a version that had moved under it. This writes at most one row and cannot conflict. /// fail on a version that had moved under it. This writes at most one row and cannot conflict.
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes /// <para>
/// nothing, and marking it modified would enlist the user row in whatever the request saves next. /// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
/// against the version from before the update and failed, so enrolling more than an hour after first
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
/// </para>
/// </remarks> /// </remarks>
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken) private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
{ {
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
return; return;
} }
await database.Users var written = await database.Users
.Where(u => u.Id == user.Id .Where(u => u.Id == user.Id
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow)) && (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
.ExecuteUpdateAsync( .ExecuteUpdateAsync(
setters => setters.SetProperty(u => u.LastSeenAtUtc, now), setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (written > 0)
{
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
}
} }
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) => private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(authorizeResult); ArgumentNullException.ThrowIfNull(authorizeResult);
var accountNotActive = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } refusal
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
if (accountNotActive)
{
await TypedResults.Problem(
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
statusCode: StatusCodes.Status403Forbidden,
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
{
["code"] = ProblemCodes.AccountNotActive,
})
.ExecuteAsync(context)
.ConfigureAwait(false);
return;
}
var enrollmentMissing = authorizeResult.Forbidden var enrollmentMissing = authorizeResult.Forbidden
&& authorizeResult.AuthorizationFailure is { } failure && authorizeResult.AuthorizationFailure is { } failure
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any(); && failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
+13 -4
View File
@@ -2,9 +2,18 @@
# #
# docker build -f src/DodoSSH.Api/Dockerfile . # docker build -f src/DodoSSH.Api/Dockerfile .
# #
# Directory.Build.props, Directory.Packages.props, NuGet.config and global.json all sit at # Directory.Build.props, Directory.Build.targets, Directory.Packages.props, NuGet.config and
# the root and all four are load-bearing here — central package management means a csproj # global.json all sit at the root and all five are load-bearing here — central package
# alone does not name a single version, and a build that cannot see them resolves nothing. # management means a csproj alone does not name a single version, and a build that cannot see
# them resolves nothing.
#
# Directory.Build.targets is the one that is easy to forget, because leaving it out breaks
# nothing until the day it does: it pins the version of the trimmer the SDK adds to
# DodoSSH.Contracts and DodoSSH.Crypto by itself (see the comment there). Without it the restore
# below asks for whatever ILLink the image's SDK bundles, which matches the lock files only
# until .NET ships a servicing release — and then this image fails with NU1004 while ci.yml,
# which does see the pin, stays green. That is how it was found: sdk:10.0-noble moved to
# 10.0.401 (ILLink 10.0.12) and this build broke with no commit touching a package.
# --------------------------------------------------------------------------------------- # ---------------------------------------------------------------------------------------
# Build # Build
@@ -26,7 +35,7 @@ WORKDIR /src
# dependency change and by nothing else. Every project in DodoSSH.Api's reference closure # dependency change and by nothing else. Every project in DodoSSH.Api's reference closure
# has to be here — restore walks ProjectReference, and a missing csproj fails the graph # has to be here — restore walks ProjectReference, and a missing csproj fails the graph
# rather than skipping a node. # rather than skipping a node.
COPY global.json NuGet.config Directory.Build.props Directory.Packages.props ./ COPY global.json NuGet.config Directory.Build.props Directory.Build.targets Directory.Packages.props ./
COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/ COPY src/DodoSSH.Api/DodoSSH.Api.csproj src/DodoSSH.Api/
COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/ COPY src/DodoSSH.Api/packages.lock.json src/DodoSSH.Api/
COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/ COPY src/DodoSSH.Contracts/DodoSSH.Contracts.csproj src/DodoSSH.Contracts/
@@ -211,10 +211,23 @@ internal sealed class VaultEventsEndpoint(
/// <summary>Runs the socket until something ends it, then closes it politely.</summary> /// <summary>Runs the socket until something ends it, then closes it politely.</summary>
/// <remarks> /// <remarks>
/// <para>
/// Three loops rather than one: reading a socket and writing to it are independent waits, and the /// Three loops rather than one: reading a socket and writing to it are independent waits, and the
/// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/> and then <em>all</em> /// clock is a third. They are joined by <see cref="Task.WhenAny(Task[])"/>, and the sender is
/// awaited before the close is written, because a close frame racing a notice frame is a protocol /// finished before the close is written, because a close frame racing a notice frame is a protocol
/// violation that presents as a client dropping its connection for no visible reason. /// violation that presents as a client dropping its connection for no visible reason.
/// </para>
/// <para>
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on
/// the socket Kestrel hands over aborts it, and an aborted socket cannot send the close frame that
/// is the only way a client learns <em>why</em> it was dropped — 4401 "reconnect with a fresh
/// token" against 1001 "the server is going away" against a failure to back off from. This was
/// once the case here, and it went unnoticed because <c>TestServer</c>'s in-memory socket survives
/// the cancellation that a real one does not. So the token stops the queue and the clock; 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, and the socket is aborted only when a peer has stopped
/// answering altogether. The relay's pump does the same, for the same reason.
/// </para>
/// </remarks> /// </remarks>
private async Task PumpAsync( private async Task PumpAsync(
WebSocket socket, WebSocket socket,
@@ -236,15 +249,21 @@ internal sealed class VaultEventsEndpoint(
VaultCount: connection.VaultCount)); VaultCount: connection.VaultCount));
var sending = SendAsync(socket, connection, pump.Token); var sending = SendAsync(socket, connection, pump.Token);
var receiving = ReceiveAsync(socket, connection, pump.Token); var receiving = ReceiveAsync(socket, connection);
var minding = MindAsync(connection, closure, pump.Token); var minding = MindAsync(connection, closure, pump.Token);
await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false); await Task.WhenAny(sending, receiving, minding).ConfigureAwait(false);
await pump.CancelAsync().ConfigureAwait(false); await pump.CancelAsync().ConfigureAwait(false);
// Nothing may still be mid-send when the close frame goes out. // Nothing may still be mid-send when the close frame goes out. A send that does not finish is
await Task.WhenAll(Settled(sending), Settled(receiving), Settled(minding)).ConfigureAwait(false); // a client that has stopped reading, which will not read a close frame either.
if (!await SettlesAsync(sending).ConfigureAwait(false))
{
socket.Abort();
}
await SettlesAsync(minding).ConfigureAwait(false);
if (lifetime.ApplicationStopping.IsCancellationRequested) if (lifetime.ApplicationStopping.IsCancellationRequested)
{ {
@@ -256,6 +275,14 @@ internal sealed class VaultEventsEndpoint(
EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason); EventsLog.ClosingConnection(logger, connection.UserId, closure.Reason);
await CloseAsync(socket, closure).ConfigureAwait(false); await CloseAsync(socket, closure).ConfigureAwait(false);
// The client's answering close is what ends the receiver. Returning before it arrives would
// dispose the socket under a close frame still in flight.
if (!await SettlesAsync(receiving).ConfigureAwait(false))
{
socket.Abort();
await SettlesAsync(receiving).ConfigureAwait(false);
}
} }
/// <summary> /// <summary>
@@ -287,8 +314,9 @@ internal sealed class VaultEventsEndpoint(
var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent); var bytes = JsonSerializer.SerializeToUtf8Bytes(frame, DodoSshJsonContext.Default.VaultEvent);
// Not cancellable, deliberately. See PumpAsync.
await socket await socket
.SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, cancellationToken) .SendAsync(bytes, WebSocketMessageType.Text, endOfMessage: true, CancellationToken.None)
.ConfigureAwait(false); .ConfigureAwait(false);
} }
} }
@@ -297,21 +325,26 @@ internal sealed class VaultEventsEndpoint(
/// Reads what the client sends, which in this version is heartbeats and a close. /// Reads what the client sends, which in this version is heartbeats and a close.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para>
/// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a /// A client cannot ask to follow a vault, and that is deliberate rather than unfinished: a
/// <c>subscribe(vaultId)</c> frame is an existence oracle for vault ids, which is the disclosure /// <c>subscribe(vaultId)</c> frame is an existence oracle for vault ids, which is the disclosure
/// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the /// <c>SyncPullEndpoint</c> answers 404 rather than 403 to avoid. Subscription is decided from the
/// caller's access and nothing else. /// caller's access and nothing else.
/// </para>
/// <para>
/// It takes no token: it ends on the client's close, a frame it refuses, or the socket failing —
/// and when the server is the one ending the connection, the close it sends is what prompts the
/// first of those. See <see cref="PumpAsync"/>.
/// </para>
/// </remarks> /// </remarks>
private static async Task ReceiveAsync( private static async Task ReceiveAsync(WebSocket socket, VaultEventConnection connection)
WebSocket socket,
VaultEventConnection connection,
CancellationToken cancellationToken)
{ {
var buffer = new byte[MaxInboundFrameBytes]; var buffer = new byte[MaxInboundFrameBytes];
while (!cancellationToken.IsCancellationRequested) while (true)
{ {
var received = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); // Not cancellable, deliberately. See PumpAsync.
var received = await socket.ReceiveAsync(buffer, CancellationToken.None).ConfigureAwait(false);
if (received.MessageType == WebSocketMessageType.Close) if (received.MessageType == WebSocketMessageType.Close)
{ {
@@ -492,31 +525,39 @@ internal sealed class VaultEventsEndpoint(
} }
/// <summary> /// <summary>
/// Awaits a pump loop, treating its cancellation and its socket faults as the ordinary end. /// Waits for a pump loop to finish, for at most the close timeout.
/// </summary> /// </summary>
/// <returns>Whether it finished.</returns>
/// <remarks> /// <remarks>
/// Every one of these loops ends by being cancelled or by the socket going away, so an exception /// Every one of these loops ends by being cancelled or by the socket going away, so an exception
/// here is the expected shape of "this connection is over" rather than a fault to propagate — and /// here is the expected shape of "this connection is over" rather than a fault to propagate — and
/// propagating it would skip the close frame the other side is waiting for. /// propagating it would skip the close frame the other side is waiting for.
/// </remarks> /// </remarks>
private static async Task Settled(Task loop) private static async Task<bool> SettlesAsync(Task loop)
{ {
try try
{ {
await loop.ConfigureAwait(false); await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
} }
catch (Exception exception) catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException) when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{ {
// Expected. return true;
} }
} }
/// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary> /// <summary>Why the socket is being closed, decided by whichever loop ended first.</summary>
/// <remarks> /// <remarks>
/// Mutable and shared, and safe without a lock for one specific reason: it is written by the pump /// Mutable and shared, and safe without a lock for one specific reason: it is written only by
/// loops and read only after <see cref="Task.WhenAll(Task[])"/> over all of them, which is a /// <see cref="MindAsync"/>, and read only after that loop has been awaited, which is a memory
/// memory barrier. Writes race only with each other, and any of them is a true answer. /// barrier. If that loop outlived the close timeout, a late write races the read, and either
/// answer is still a true one.
/// </remarks> /// </remarks>
private sealed class Closure(int code, string reason) private sealed class Closure(int code, string reason)
{ {
@@ -0,0 +1,24 @@
using DodoSSH.Api.Features.Relay;
namespace DodoSSH.Api.Features.Events;
/// <summary>
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
/// </summary>
/// <remarks>
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
/// touch any of the places a removal happens.
/// </remarks>
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
{
/// <inheritdoc />
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
/// <inheritdoc />
public void VaultAccessChanged(Guid userId)
{
hub.VaultAccessChanged(userId);
_ = relay.AccessChangedAsync(userId);
}
}
@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Crypto; using DodoSSH.Crypto;
using DodoSSH.Domain; using DodoSSH.Domain;
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
byte[] fingerprint, byte[] fingerprint,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var strategy = database.Database.CreateExecutionStrategy(); return await database.ExecuteFreshAsync([user], async () =>
return await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -26,9 +26,6 @@ internal sealed class GetMetaEndpoint(
/// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary> /// <summary>Sync semantics version. Bumped when push or pull behaviour changes.</summary>
internal const int SyncProtocolVersion = 1; internal const int SyncProtocolVersion = 1;
/// <summary>Feature flag for the relay.</summary>
internal const string RelayFeature = "relay";
/// <inheritdoc /> /// <inheritdoc />
public override void Configure() public override void Configure()
{ {
@@ -50,7 +47,7 @@ internal sealed class GetMetaEndpoint(
List<string> features = ["teams"]; List<string> features = ["teams"];
if (relay.Value.Enabled) if (relay.Value.Enabled)
{ {
features.Add(RelayFeature); features.Add(RelayProtocol.Feature);
} }
// Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is // Advertised so a client knows whether to hold a socket open or rely on its timer. Absence is
@@ -0,0 +1,62 @@
using DodoSSH.Api.Authorization;
using DodoSSH.Domain.Authorization;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
/// </summary>
/// <remarks>
/// <para>
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
/// their keys and their vault list away at once, and left any shell they had open through the relay running
/// into the team's machines until it went idle.
/// </para>
/// <para>
/// Told by the same announcement that tells their event socket to re-read its vaults — see
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
/// has already committed and answered, and a slow re-check must not hold it.
/// </para>
/// </remarks>
internal sealed class RelayAccessWatch(
RelaySessionRegistry sessions,
IServiceScopeFactory scopes,
ILogger<RelayAccessWatch> logger)
{
/// <summary>Re-checks every session the user has open, in the background.</summary>
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
internal Task AccessChangedAsync(Guid userId)
{
var open = sessions.SessionsOf(userId);
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
}
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
foreach (var lease in open)
{
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
.ConfigureAwait(false);
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
{
lease.Revoke();
}
}
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
RelayLog.AccessRecheckFailed(logger, userId, exception);
}
}
}
@@ -0,0 +1,192 @@
using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Setup;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Decides which addresses and ports the relay may dial. ADR 0004 §3, in the order it is written.
/// </summary>
/// <remarks>
/// <para>
/// This runs on <em>addresses</em>, never on names. The name is resolved once, at ticket issue, and what
/// this permits goes into the ticket; the socket dials those addresses and nothing else. A check on the
/// name would be defeated by DNS rebinding — answer with a public address for the check and a loopback
/// one for the dial — which is the attack ADR 0004 is built around.
/// </para>
/// <para>
/// Three layers, and the order is the security property. The <see cref="Refused"/> list comes first and
/// has no configuration at all: nothing an operator sets, and nothing a host row says, reaches a
/// loopback, link-local, unspecified, multicast or metadata address. Then the operator's own denials,
/// then their allow-list, then the private-network switch.
/// </para>
/// <para>
/// <b>An address that embeds another is judged as the one it embeds.</b> <c>::ffff:127.0.0.1</c> is
/// loopback however it is spelled, and a check that compared it against IPv6 loopback alone would pass
/// it — the classic bypass the ADR names. The NAT64 well-known prefix is unwrapped the same way, because
/// on a network with a NAT64 gateway <c>64:ff9b::a9fe:a9fe</c> <em>is</em> the metadata endpoint.
/// </para>
/// </remarks>
internal sealed class RelayAddressPolicy
{
/// <summary>
/// Addresses the relay never dials, whatever it is configured to do.
/// </summary>
/// <remarks>
/// Broader than the ADR's list in two places, both deliberately. <c>240.0.0.0/4</c> — reserved, and
/// containing the limited broadcast address — has no host worth reaching. And IPv6 <c>::/96</c>, the
/// deprecated IPv4-compatible block, covers the unspecified address and loopback while also refusing
/// the spellings of IPv4 that a stack may or may not map; nothing legitimate lives there.
/// </remarks>
private static readonly IPNetwork[] Refused =
[
// IPv4: "this network", loopback, link-local (which holds 169.254.169.254), multicast, reserved.
IPNetwork.Parse("0.0.0.0/8"),
IPNetwork.Parse("127.0.0.0/8"),
IPNetwork.Parse("169.254.0.0/16"),
IPNetwork.Parse("224.0.0.0/4"),
IPNetwork.Parse("240.0.0.0/4"),
// Alibaba Cloud's metadata endpoint, which sits inside carrier-grade NAT space and so would
// otherwise be permitted as private — the range a Tailnet also uses, which is why the whole
// block cannot simply be refused.
IPNetwork.Parse("100.100.100.200/32"),
// IPv6: IPv4-compatible (with unspecified and loopback), link-local, multicast, and the AWS
// metadata endpoint's IPv6 address.
IPNetwork.Parse("::/96"),
IPNetwork.Parse("fe80::/10"),
IPNetwork.Parse("ff00::/8"),
IPNetwork.Parse("fd00:ec2::254/128"),
];
/// <summary>
/// Ranges <see cref="RelayOptions.AllowPrivateNetworks"/> governs.
/// </summary>
/// <remarks>
/// Carrier-grade NAT is here rather than treated as public, because it is where a Tailnet lives and
/// "the server is on the same Tailnet" is one of the shapes the relay exists for. Site-local
/// <c>fec0::/10</c> is deprecated and was private while it lived.
/// </remarks>
private static readonly IPNetwork[] Private =
[
IPNetwork.Parse("10.0.0.0/8"),
IPNetwork.Parse("172.16.0.0/12"),
IPNetwork.Parse("192.168.0.0/16"),
IPNetwork.Parse("100.64.0.0/10"),
IPNetwork.Parse("fc00::/7"),
IPNetwork.Parse("fec0::/10"),
];
/// <summary>The NAT64 well-known prefix, RFC 6052, whose low 32 bits are an IPv4 address.</summary>
private static readonly IPNetwork Nat64 = IPNetwork.Parse("64:ff9b::/96");
/// <summary>
/// Ports the relay never dials.
/// </summary>
/// <remarks>
/// SMTP and nothing else, per ADR 0004: a relay that reached port 25 is a spam cannon with the
/// operator's address on it. Database ports stay open, because forwarding to one is a headline use,
/// and the control that matters there is who may dial the host at all.
/// </remarks>
private static readonly int[] RefusedPorts = [25, 465, 587];
private readonly bool allowPrivate;
private readonly IPNetwork[] allowed;
private readonly IPNetwork[] denied;
public RelayAddressPolicy(RelayOptions options)
{
ArgumentNullException.ThrowIfNull(options);
allowPrivate = options.AllowPrivateNetworks;
allowed = ParseAll(options.AllowedNetworks);
denied = ParseAll(options.DeniedNetworks);
}
/// <summary>Whether a port may be dialled at all.</summary>
internal static bool PermitsPort(int port) =>
port is >= 1 and <= 65535 && !RefusedPorts.Contains(port);
/// <summary>
/// Whether every entry is a network this policy can read.
/// </summary>
/// <remarks>
/// For startup validation. A typo in a deny list that was silently skipped would be a hole the
/// operator believes is closed, so an unreadable entry stops the server instead.
/// <para>
/// Host bits are refused as well, although <see cref="IPNetwork.TryParse(string?, out IPNetwork)"/>
/// would mask them off quietly. <c>10.20.5.7/16</c> could be a /16 written from memory or a /32 with
/// the wrong suffix, and in a deny list those differ by sixty-five thousand addresses.
/// </para>
/// </remarks>
internal static bool AreNetworks(IEnumerable<string> entries) =>
entries.All(entry =>
IPNetwork.TryParse(entry, out var network)
&& IPAddress.TryParse(entry.AsSpan(0, entry.IndexOf('/', StringComparison.Ordinal)), out var written)
&& written.Equals(network.BaseAddress));
/// <summary>
/// The address the relay would actually be talking to, with any embedding unwrapped.
/// </summary>
internal static IPAddress Normalise(IPAddress address)
{
ArgumentNullException.ThrowIfNull(address);
if (address.IsIPv4MappedToIPv6)
{
return address.MapToIPv4();
}
if (address.AddressFamily == AddressFamily.InterNetworkV6 && Nat64.Contains(address))
{
var bytes = address.GetAddressBytes();
return new IPAddress(bytes.AsSpan(12, 4));
}
// A scope id is how an address names an interface on this machine, which is not something a
// host row gets to choose. Dropped, so what is judged is what is dialled.
return address.AddressFamily == AddressFamily.InterNetworkV6 && address.ScopeId != 0
? new IPAddress(address.GetAddressBytes())
: address;
}
/// <summary>Whether the relay may dial this address.</summary>
/// <remarks>
/// Judges the normalised address. Callers should dial <see cref="Normalise"/>'s answer too, so that
/// the address checked and the address connected to are the same object rather than two spellings
/// the operating system is trusted to agree about.
/// </remarks>
internal bool Permits(IPAddress address)
{
var target = Normalise(address);
if (Refused.Any(network => Within(network, target)))
{
return false;
}
if (denied.Any(network => Within(network, target)))
{
return false;
}
if (allowed.Length > 0)
{
return allowed.Any(network => Within(network, target));
}
return allowPrivate || !Private.Any(network => Within(network, target));
}
/// <remarks>
/// <see cref="IPNetwork.Contains"/> answers false across address families rather than throwing, so
/// a list mixing IPv4 and IPv6 needs no sorting — but it is spelled out here because the one-line
/// alternative reads as though it compared across families.
/// </remarks>
private static bool Within(IPNetwork network, IPAddress address) =>
network.BaseAddress.AddressFamily == address.AddressFamily && network.Contains(address);
private static IPNetwork[] ParseAll(IEnumerable<string> entries) =>
[.. entries.Select(entry => IPNetwork.Parse(entry))];
}
@@ -0,0 +1,759 @@
using System.Buffers;
using System.Net;
using System.Net.WebSockets;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using FastEndpoints;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Npgsql;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// The relay socket: a ticket in, raw bytes to one host and back.
/// </summary>
/// <remarks>
/// <para>
/// Anonymous at the HTTP layer, because the ticket is the authorization and it is the whole of it. A
/// ticket carries no API authority — it names one host, one set of addresses and one session — so this
/// endpoint can accept it from a client that cannot set an <c>Authorization</c> header on an upgrade,
/// which is the constraint ADR 0004 was written around. It is verified, checked for freshness and
/// redeemed before the upgrade is accepted, so a refused ticket costs a problem document and no socket.
/// </para>
/// <para>
/// <b>Nothing here is scoped to the request.</b> A session lasts as long as a shell does, which is hours,
/// and anything scoped held across it is a pooled database connection held across it. The database is
/// touched three times — redeem, open the audit row, close it — each from a scope of its own that is
/// disposed before the next byte moves. That is ADR 0004's consequence about <c>DbContext</c> lifetime.
/// </para>
/// <para>
/// Backpressure is the await. Each direction reads one buffer, writes it, and does not read again until
/// the write has completed, so a slow reader on either side stops this side reading, which fills the
/// kernel's receive window, which throttles the sender at the far end. Nothing is queued in between, and
/// a session costs its two buffers whatever the peers do.
/// </para>
/// </remarks>
internal sealed class RelayConnectEndpoint(
IServiceScopeFactory scopes,
IRelayNetwork network,
RelayTicketCodec codec,
RelaySessionRegistry sessions,
IOptions<RelayOptions> options,
TimeProvider clock,
IHostApplicationLifetime lifetime,
ILogger<RelayConnectEndpoint> logger)
: EndpointWithoutRequest
{
/// <summary>
/// One direction's buffer.
/// </summary>
/// <remarks>
/// Room for an SSH packet at the size OpenSSH sends them, so a bulk transfer moves in one read and
/// one write per packet rather than several.
/// </remarks>
private const int BufferBytes = 32 * 1024;
/// <summary>How long a close handshake, or a peer's last write, may take before the socket is dropped.</summary>
private static readonly TimeSpan CloseTimeout = TimeSpan.FromSeconds(5);
/// <inheritdoc />
public override void Configure()
{
Get(RelayProtocol.ConnectPath);
// The ticket is the authorization. See the remarks on this class, and ADR 0004.
AllowAnonymous();
Description(b => b
.WithName("RelayConnect")
.WithSummary("Pipes raw bytes between a WebSocket and the host a relay ticket names.")
.WithTags("Relay"));
}
/// <inheritdoc />
public override async Task HandleAsync(CancellationToken ct)
{
var ticket = await AdmitTicketAsync(ct).ConfigureAwait(false);
if (ticket is null)
{
return;
}
var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
try
{
using var socket = await HttpContext.WebSockets
.AcceptWebSocketAsync(new WebSocketAcceptContext { SubProtocol = RelayProtocol.SubProtocol })
.ConfigureAwait(false);
if (lease is null)
{
RelayLog.LimitReached(logger, ticket.UserId, sessions.Count);
await CloseAsync(
socket,
new Ending(
RelayCloseReason.Unspecified,
RelayProtocol.TooManySessionsCloseCode,
"Too many relay sessions are open."))
.ConfigureAwait(false);
return;
}
if (lifetime.ApplicationStopping.IsCancellationRequested)
{
await CloseAsync(socket, ShuttingDown).ConfigureAwait(false);
return;
}
await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
}
finally
{
// Inside a try that starts before the upgrade, for the reason VaultEventsEndpoint gives: an
// accept that throws would otherwise hold a place against this user's cap forever.
lease?.Dispose();
}
}
private static Ending ShuttingDown { get; } = new(
RelayCloseReason.ServerShutdown,
(int)WebSocketCloseStatus.EndpointUnavailable,
"The server is shutting down.");
/// <summary>
/// Answers everything that is not a redeemable ticket, as ordinary HTTP, before any socket exists.
/// </summary>
/// <returns>The redeemed ticket, or null when a response has been sent.</returns>
/// <remarks>
/// A ticket that is forged, expired, spent, or simply absent all answer 401
/// <c>relay-ticket-invalid</c>, with one remedy — ask for another — and no hint of which it was.
/// </remarks>
private async Task<RelayTicket?> AdmitTicketAsync(CancellationToken cancellationToken)
{
if (!options.Value.Enabled)
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status404NotFound,
ProblemCodes.RelayUnavailable,
"This server does not relay. GET /api/v1/meta lists the features it does offer."))
.ConfigureAwait(false);
return null;
}
var offered = HttpContext.WebSockets.WebSocketRequestedProtocols;
if (!HttpContext.WebSockets.IsWebSocketRequest
|| !offered.Contains(RelayProtocol.SubProtocol, StringComparer.Ordinal))
{
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status400BadRequest,
ProblemCodes.MalformedRequest,
"This endpoint is a WebSocket. Send an upgrade request offering the "
+ $"'{RelayProtocol.SubProtocol}' subprotocol and the ticket as a second one, "
+ $"'{RelayProtocol.TicketProtocolPrefix}<ticket>'."))
.ConfigureAwait(false);
return null;
}
var sealedTicket = offered
.FirstOrDefault(protocol => protocol.StartsWith(RelayProtocol.TicketProtocolPrefix, StringComparison.Ordinal))
?[RelayProtocol.TicketProtocolPrefix.Length..];
var ticket = sealedTicket is { Length: > 0 } ? codec.TryOpen(sealedTicket) : null;
if (ticket is null || !await RedeemAsync(ticket, cancellationToken).ConfigureAwait(false))
{
RelayLog.TicketRefused(logger, ticket?.SessionId);
await Send.ResultAsync(Problems.Coded(
StatusCodes.Status401Unauthorized,
ProblemCodes.RelayTicketInvalid,
"This relay ticket is not valid: it is expired, already used, or was not issued here. "
+ $"Ask for another from POST {RelayProtocol.TicketsPath}."))
.ConfigureAwait(false);
return null;
}
return ticket;
}
/// <summary>
/// Spends the ticket, or answers false when it has been spent already.
/// </summary>
/// <remarks>
/// <para>
/// A unique insert, which is the only form of "once" that holds across nodes: two redemptions racing
/// on two nodes both attempt the insert and the database lets exactly one of them win.
/// </para>
/// <para>
/// Rows for tickets that can no longer verify are swept on the way. They guard nothing, and sweeping
/// here — on a path that runs once per session rather than once per request — keeps the table the
/// size of the last thirty seconds without a background job to forget about.
/// </para>
/// </remarks>
private async Task<bool> RedeemAsync(RelayTicket ticket, CancellationToken cancellationToken)
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var now = clock.GetUtcNow();
await database.RelayTicketUses
.Where(use => use.ExpiresAtUtc < now)
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
database.RelayTicketUses.Add(new RelayTicketUse
{
TicketId = ticket.TicketId,
ExpiresAtUtc = ticket.ExpiresAt,
UsedAtUtc = now,
});
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
return true;
}
catch (DbUpdateException exception)
when (string.Equals(
(exception.InnerException as PostgresException)?.SqlState,
PostgresErrorCodes.UniqueViolation,
StringComparison.Ordinal))
{
return false;
}
}
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
private async Task RelayAsync(
WebSocket socket,
RelayTicket ticket,
CancellationToken revoked,
CancellationToken requestAborted)
{
var started = clock.GetUtcNow();
await OpenAuditAsync(ticket, started).ConfigureAwait(false);
var dialled = await DialAsync(ticket, requestAborted).ConfigureAwait(false);
if (dialled is not var (target, address))
{
RelayLog.TargetUnreachable(logger, ticket.SessionId, ticket.HostId);
var unreachable = new Ending(
RelayCloseReason.TargetUnreachable,
RelayProtocol.TargetUnreachableCloseCode,
"The relay could not reach the host.");
await CloseAsync(socket, unreachable).ConfigureAwait(false);
await CloseAuditAsync(ticket.SessionId, null, new Counters(clock, started), unreachable.Reason).ConfigureAwait(false);
return;
}
var counters = new Counters(clock, started);
Ending ending;
await using (target.ConfigureAwait(false))
{
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
ending = await PumpAsync(socket, target, counters, started, revoked, requestAborted).ConfigureAwait(false);
}
RelayLog.SessionClosed(
logger,
ticket.SessionId,
ending.Reason,
counters.ClientToTarget,
counters.TargetToClient);
await CloseAuditAsync(ticket.SessionId, address, counters, ending.Reason).ConfigureAwait(false);
}
/// <summary>
/// Connects to the first of the ticket's addresses that answers, within the connect timeout overall.
/// </summary>
/// <remarks>
/// Overall rather than per address, so a name with sixteen dead addresses costs five seconds rather
/// than eighty. In the order resolved, which is the order the operating system's resolver prefers.
/// </remarks>
private async Task<(Stream Target, IPAddress Address)?> DialAsync(
RelayTicket ticket,
CancellationToken cancellationToken)
{
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(options.Value.ConnectTimeout);
foreach (var address in ticket.TargetAddresses)
{
try
{
var target = await network
.ConnectAsync(address, ticket.TargetPort, timeout.Token)
.ConfigureAwait(false);
return (target, address);
}
catch (OperationCanceledException)
{
return null;
}
catch (Exception exception) when (exception is IOException or System.Net.Sockets.SocketException)
{
// The next address, if there is one.
}
}
return null;
}
/// <summary>
/// Runs both directions and the clock until one of them ends the session, then ends it politely.
/// </summary>
/// <remarks>
/// <para>
/// <b>No operation on the WebSocket is ever cancelled.</b> Cancelling a pending receive or send on a
/// .NET WebSocket aborts it, and an aborted socket cannot send the close frame that is the only way
/// a client learns <em>why</em> its session ended — which, for an idle timeout or a drain, is the
/// difference between "reconnect" and "something is broken". So the token stops the target side and
/// the clock; 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, and the socket is aborted only when a peer
/// has stopped answering altogether.
/// </para>
/// </remarks>
private async Task<Ending> PumpAsync(
WebSocket socket,
Stream target,
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken requestAborted)
{
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
var minding = MindAsync(counters, started, revoked, pump.Token);
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
var ending = await first.ConfigureAwait(false) ?? new Ending(
RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
await pump.CancelAsync().ConfigureAwait(false);
// The only sender must be done before the close frame is written: a WebSocket permits one send
// at a time and faults permanently on a second.
if (!await SettlesAsync(downstream).ConfigureAwait(false))
{
socket.Abort();
}
await CloseAsync(socket, ending).ConfigureAwait(false);
if (!await SettlesAsync(upstream).ConfigureAwait(false))
{
socket.Abort();
await SettlesAsync(upstream).ConfigureAwait(false);
}
await SettlesAsync(minding).ConfigureAwait(false);
return ending;
}
/// <summary>Client to target: every binary message is the next bytes of the stream.</summary>
private static async Task<Ending?> ClientToTargetAsync(
WebSocket socket,
Stream target,
Counters counters,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
try
{
while (true)
{
ValueWebSocketReceiveResult received;
try
{
// Not cancellable, deliberately. See PumpAsync.
received = await socket.ReceiveAsync(buffer.AsMemory(), CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is WebSocketException or OperationCanceledException)
{
return ClientGone;
}
if (received.MessageType == WebSocketMessageType.Close)
{
return ClientGone;
}
// A byte pipe has no text. A client sending some has the wrong idea about this endpoint,
// and forwarding it into an SSH stream would corrupt that stream at best.
if (received.MessageType != WebSocketMessageType.Binary)
{
return new Ending(
RelayCloseReason.Error,
(int)WebSocketCloseStatus.InvalidMessageType,
"The relay carries binary messages only.");
}
try
{
await target.WriteAsync(buffer.AsMemory(0, received.Count), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (IOException)
{
return TargetGone;
}
counters.AddClientToTarget(received.Count);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>Target to client: every read becomes one binary message.</summary>
private static async Task<Ending?> TargetToClientAsync(
Stream target,
WebSocket socket,
Counters counters,
CancellationToken cancellationToken)
{
var buffer = ArrayPool<byte>.Shared.Rent(BufferBytes);
try
{
while (true)
{
int read;
try
{
read = await target.ReadAsync(buffer.AsMemory(0, BufferBytes), cancellationToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
return null;
}
catch (IOException)
{
return TargetGone;
}
if (read == 0)
{
return TargetGone;
}
try
{
// Not cancellable, deliberately. See PumpAsync.
await socket
.SendAsync(
buffer.AsMemory(0, read),
WebSocketMessageType.Binary,
endOfMessage: true,
CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is WebSocketException or ObjectDisposedException)
{
return ClientGone;
}
counters.AddTargetToClient(read);
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
/// <summary>
/// Ends the session at the idle timeout, the lifetime cap, or the end of a shutdown's drain.
/// </summary>
/// <remarks>
/// <para>
/// A shutdown does not end a session; it starts a countdown. ADR 0004 asks that a
/// <c>docker compose up -d</c> not guillotine live shells, so a session in progress when the server is
/// told to stop is given the drain timeout to finish — most will, because the user was already
/// closing it or the deployment waits — and is closed with 1001 only when that runs out, which tells
/// the client to reconnect rather than to report a fault. The host's own shutdown timeout is set
/// longer than the drain for this to be possible at all.
/// </para>
/// <para>
/// Idle means no bytes in either direction. WebSocket pings are not bytes of the stream and do not
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
/// </para>
/// </remarks>
private async Task<Ending?> MindAsync(
Counters counters,
DateTimeOffset started,
CancellationToken revoked,
CancellationToken cancellationToken)
{
var settings = options.Value;
var hardDeadline = started + settings.MaxSessionDuration;
DateTimeOffset? drainDeadline = null;
while (!cancellationToken.IsCancellationRequested)
{
var now = clock.GetUtcNow();
if (drainDeadline is null && lifetime.ApplicationStopping.IsCancellationRequested)
{
drainDeadline = now + settings.DrainTimeout;
}
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
{
return ending;
}
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
// than from whenever the idle clock next came round.
using var wake = drainDeadline is null
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
try
{
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
}
catch (OperationCanceledException)
{
return null;
}
}
return null;
}
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
/// <remarks>
/// Revocation first, because it is the one reason here that is about the user rather than the session:
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
/// </remarks>
private static Ending? Due(
DateTimeOffset now,
bool revoked,
DateTimeOffset hardDeadline,
DateTimeOffset? drainDeadline,
DateTimeOffset idleDeadline)
{
if (revoked)
{
return AccessRevoked;
}
if (now >= hardDeadline)
{
return new Ending(
RelayCloseReason.DurationLimit,
RelayProtocol.DurationLimitCloseCode,
"This relay session reached its maximum lifetime.");
}
if (drainDeadline is { } drain && now >= drain)
{
return ShuttingDown;
}
return now >= idleDeadline
? new Ending(
RelayCloseReason.IdleTimeout,
RelayProtocol.IdleTimeoutCloseCode,
"This relay session carried nothing for the idle timeout.")
: null;
}
private static Ending AccessRevoked { get; } = new(
RelayCloseReason.AccessRevoked,
RelayProtocol.AccessRevokedCloseCode,
"You no longer have access to this host.");
private static Ending ClientGone { get; } =
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
private static Ending TargetGone { get; } =
new(RelayCloseReason.TargetClosed, (int)WebSocketCloseStatus.NormalClosure, "The host closed the connection.");
/// <summary>
/// Opens the audit row, before the target is dialled, so a session that never reached its host is
/// still one the operator can see.
/// </summary>
private async Task OpenAuditAsync(RelayTicket ticket, DateTimeOffset started)
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
database.RelaySessions.Add(new RelaySession
{
Id = ticket.SessionId,
UserId = ticket.UserId,
HostId = ticket.HostId,
VaultId = ticket.VaultId,
TargetHost = ticket.TargetHost,
TargetPort = ticket.TargetPort,
ClientAddress = HttpContext.Connection.RemoteIpAddress?.ToString(),
StartedAtUtc = started,
});
// Not the request's token: the row has to be written even for a client that has already gone,
// or a session that was cut off would be the one with no record.
await database.SaveChangesAsync(CancellationToken.None).ConfigureAwait(false);
}
/// <summary>
/// Closes the audit row. A failure is logged and swallowed: the bytes have already moved, and
/// throwing here would only turn a finished session into an unhandled exception.
/// </summary>
private async Task CloseAuditAsync(
Guid sessionId,
IPAddress? address,
Counters counters,
RelayCloseReason reason)
{
try
{
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
var ended = clock.GetUtcNow();
var dialled = address?.ToString();
await database.RelaySessions
.Where(session => session.Id == sessionId)
.ExecuteUpdateAsync(
set => set
.SetProperty(session => session.EndedAtUtc, ended)
.SetProperty(session => session.TargetAddress, dialled)
.SetProperty(session => session.BytesClientToTarget, counters.ClientToTarget)
.SetProperty(session => session.BytesTargetToClient, counters.TargetToClient)
.SetProperty(session => session.CloseReason, reason),
CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is DbUpdateException or NpgsqlException or InvalidOperationException)
{
RelayLog.AuditCloseFailed(logger, sessionId, exception);
}
}
private static async Task CloseAsync(WebSocket socket, Ending ending)
{
if (socket.State is not (WebSocketState.Open or WebSocketState.CloseReceived))
{
return;
}
using var timeout = new CancellationTokenSource(CloseTimeout);
try
{
await socket
.CloseOutputAsync((WebSocketCloseStatus)ending.Code, ending.Description, timeout.Token)
.ConfigureAwait(false);
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or ObjectDisposedException)
{
// The peer is already gone. There is nothing to tell it.
}
}
/// <summary>Waits for a loop to finish, for at most the close timeout.</summary>
/// <returns>Whether it finished.</returns>
private static async Task<bool> SettlesAsync(Task loop)
{
try
{
await loop.WaitAsync(CloseTimeout).ConfigureAwait(false);
return true;
}
catch (TimeoutException)
{
return false;
}
catch (Exception exception)
when (exception is OperationCanceledException or WebSocketException or IOException or ObjectDisposedException)
{
return true;
}
}
/// <summary>How a session ended, and what the client is told.</summary>
private sealed record Ending(RelayCloseReason Reason, int Code, string Description);
/// <summary>
/// Byte counts and the time of the last byte, written by the two directions and read by the clock.
/// </summary>
private sealed class Counters(TimeProvider clock, DateTimeOffset started)
{
private long clientToTarget;
private long targetToClient;
private long lastActivityTicks = started.UtcTicks;
internal long ClientToTarget => Interlocked.Read(ref clientToTarget);
internal long TargetToClient => Interlocked.Read(ref targetToClient);
internal DateTimeOffset LastActivity => new(Interlocked.Read(ref lastActivityTicks), TimeSpan.Zero);
internal void AddClientToTarget(int count)
{
Interlocked.Add(ref clientToTarget, count);
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
}
internal void AddTargetToClient(int count)
{
Interlocked.Add(ref targetToClient, count);
Interlocked.Exchange(ref lastActivityTicks, clock.GetUtcNow().UtcTicks);
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using DodoSSH.Domain;
namespace DodoSSH.Api.Features.Relay;
/// <summary>Source-generated log messages for the relay.</summary>
/// <remarks>
/// <para>
/// Ids, counts and reasons. Never a ticket, which is a bearer credential for thirty seconds, and never
/// the target's address: that is in the audit table, where retention is the operator's decision, and
/// copying it into a log would make it the log pipeline's decision too.
/// </para>
/// <para>
/// <see cref="TargetRejected"/> is the one place the reason for a refused target is written down. The
/// client is told nothing specific, deliberately — see <c>RelayTicketEndpoint</c> — so this line is how
/// an operator answers "why will the relay not reach this host".
/// </para>
/// </remarks>
internal static partial class RelayLog
{
[LoggerMessage(
EventId = 2301,
Level = LogLevel.Information,
Message = "Relay ticket refused for user {UserId} and host {HostId}: {Reason}.")]
internal static partial void TargetRejected(ILogger logger, Guid userId, Guid hostId, string reason);
[LoggerMessage(
EventId = 2302,
Level = LogLevel.Debug,
Message = "Relay ticket issued to user {UserId} for host {HostId}, session {SessionId}, with "
+ "{AddressCount} permitted address(es).")]
internal static partial void TicketIssued(
ILogger logger,
Guid userId,
Guid hostId,
Guid sessionId,
int addressCount);
/// <remarks>
/// The session id is known only for a ticket that verified and had already been spent, which is the
/// case worth an operator's attention: a replay, or a client retrying a redemption it thinks failed.
/// </remarks>
[LoggerMessage(
EventId = 2303,
Level = LogLevel.Information,
Message = "Relay ticket refused at the socket (session {SessionId}): forged, expired or already used.")]
internal static partial void TicketRefused(ILogger logger, Guid? sessionId);
[LoggerMessage(
EventId = 2304,
Level = LogLevel.Information,
Message = "Relay session refused for user {UserId}: a session limit is met, with {SessionCount} "
+ "open on this node.")]
internal static partial void LimitReached(ILogger logger, Guid userId, int sessionCount);
[LoggerMessage(
EventId = 2305,
Level = LogLevel.Information,
Message = "Relay session {SessionId} could not reach host {HostId} on any permitted address.")]
internal static partial void TargetUnreachable(ILogger logger, Guid sessionId, Guid hostId);
[LoggerMessage(
EventId = 2306,
Level = LogLevel.Debug,
Message = "Relay session {SessionId} opened for user {UserId} to host {HostId}; {SessionCount} "
+ "open on this node.")]
internal static partial void SessionOpened(
ILogger logger,
Guid sessionId,
Guid userId,
Guid hostId,
int sessionCount);
[LoggerMessage(
EventId = 2307,
Level = LogLevel.Debug,
Message = "Relay session {SessionId} closed ({Reason}) after {ClientToTarget} byte(s) up and "
+ "{TargetToClient} down.")]
internal static partial void SessionClosed(
ILogger logger,
Guid sessionId,
RelayCloseReason reason,
long clientToTarget,
long targetToClient);
/// <remarks>
/// A warning rather than an error: the session itself is over and was fine. What is lost is the
/// audit row's second half, which leaves it looking like a session still open.
/// </remarks>
[LoggerMessage(
EventId = 2308,
Level = LogLevel.Warning,
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
/// <remarks>
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
/// existed, and each still ends at its idle timeout or its lifetime.
/// </remarks>
[LoggerMessage(
EventId = 2309,
Level = LogLevel.Warning,
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, Exception exception);
}
@@ -0,0 +1,61 @@
using System.Net;
using System.Net.Sockets;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// The two things the relay asks of the network: what a name resolves to, and a connection to an address.
/// </summary>
/// <remarks>
/// <para>
/// A seam for tests, and a narrow one on purpose. <see cref="RelayAddressPolicy"/> is not behind it:
/// whatever this answers, the policy judges, and a test that replaces this still runs every address
/// through the real rules. What a test cannot do without the seam is dial anything, because every
/// address a test machine can reach on its own — loopback — is one the policy refuses outright.
/// </para>
/// </remarks>
internal interface IRelayNetwork
{
/// <summary>Resolves a stored hostname, which may be an address literal.</summary>
Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken);
/// <summary>Opens a TCP connection to one address.</summary>
/// <returns>A stream that owns the connection.</returns>
Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken);
}
/// <summary>The operating system's resolver and sockets.</summary>
internal sealed class SystemRelayNetwork : IRelayNetwork
{
/// <inheritdoc />
public Task<IPAddress[]> ResolveAsync(string host, CancellationToken cancellationToken) =>
// A literal parses to itself without a lookup, which is what an address typed into a host
// editor should do — and what makes "10.0.0.5" mean the same thing on every node.
IPAddress.TryParse(host, out var literal)
? Task.FromResult<IPAddress[]>([literal])
: Dns.GetHostAddressesAsync(host, cancellationToken);
/// <inheritdoc />
public async Task<Stream> ConnectAsync(IPAddress address, int port, CancellationToken cancellationToken)
{
var socket = new Socket(address.AddressFamily, SocketType.Stream, ProtocolType.Tcp)
{
// An interactive shell is a stream of keystrokes; Nagle would hold each one back for the
// acknowledgement of the last, which is the latency the user feels as a sticky terminal.
NoDelay = true,
};
try
{
await socket.ConnectAsync(new IPEndPoint(address, port), cancellationToken).ConfigureAwait(false);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
}
@@ -0,0 +1,150 @@
using DodoSSH.Api.Setup;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Counts the relay sessions open on this node, and refuses the ones over ADR 0004's limits.
/// </summary>
/// <remarks>
/// <para>
/// Per node, and so per user <em>per node</em>: a user behind a round-robin balancer with three nodes
/// could hold three times the per-user cap. That is accepted rather than fixed, because fixing it means
/// shared state on the hot path of every connect, and the cap exists to stop one account exhausting a
/// node — which a per-node count does exactly.
/// </para>
/// <para>
/// A singleton, because a session outlives the request that opened it. It holds counts and nothing else;
/// the sockets belong to the handlers that opened them.
/// </para>
/// </remarks>
internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
{
private readonly Lock gate = new();
private readonly Dictionary<Guid, int> perUser = [];
private readonly HashSet<Lease> open = [];
private int total;
/// <summary>How many sessions are open on this node.</summary>
internal int Count
{
get
{
lock (gate)
{
return total;
}
}
}
/// <summary>Whether a session for this user would be admitted now.</summary>
/// <remarks>
/// Advisory, for the ticket endpoint: it lets a caller already at the cap hear so as an HTTP status
/// before it has spent a ticket, but only <see cref="TryAdmit"/> decides, since the answer here can
/// be stale by the time the socket arrives — possibly at another node.
/// </remarks>
internal bool HasRoomFor(Guid userId)
{
var settings = options.Value;
lock (gate)
{
return total < settings.MaxConcurrentSessionsTotal
&& perUser.GetValueOrDefault(userId) < settings.MaxConcurrentSessionsPerUser;
}
}
/// <summary>Admits a session, or answers null when a limit is met.</summary>
/// <returns>A lease that gives the place back when disposed.</returns>
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
{
lock (gate)
{
return [.. open.Where(lease => lease.UserId == userId)];
}
}
internal Lease? TryAdmit(Guid userId, Guid vaultId)
{
var settings = options.Value;
lock (gate)
{
var held = perUser.GetValueOrDefault(userId);
if (total >= settings.MaxConcurrentSessionsTotal || held >= settings.MaxConcurrentSessionsPerUser)
{
return null;
}
perUser[userId] = held + 1;
total++;
var lease = new Lease(this, userId, vaultId);
open.Add(lease);
return lease;
}
}
private void Release(Lease lease, Guid userId)
{
lock (gate)
{
open.Remove(lease);
total--;
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
// session open and not of every user who ever had one.
if (perUser[userId] <= 1)
{
perUser.Remove(userId);
}
else
{
perUser[userId]--;
}
}
}
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
{
private readonly CancellationTokenSource revoked = new();
private int released;
/// <summary>Whose session this is.</summary>
internal Guid UserId { get; } = userId;
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
internal Guid VaultId { get; } = vaultId;
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
internal CancellationToken Revoked => revoked.Token;
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
internal void Revoke()
{
try
{
revoked.Cancel();
}
catch (ObjectDisposedException)
{
// Already over.
}
}
/// <inheritdoc />
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0)
{
registry.Release(this, UserId);
revoked.Dispose();
}
}
}
}
@@ -0,0 +1,171 @@
using System.Net;
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.WebUtilities;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Everything the relay socket needs to know, decided by the ticket endpoint.
/// </summary>
/// <remarks>
/// The addresses are the resolved ones and the socket dials nothing else. There is no hostname for it
/// to resolve again — <see cref="TargetHost"/> is carried for the audit row only — which is what keeps a
/// DNS answer that changes between issue and connect from changing where the bytes go.
/// </remarks>
/// <param name="TicketId">The replay guard's key.</param>
/// <param name="SessionId">The audit row's id, already given to the client.</param>
/// <param name="UserId">Who asked.</param>
/// <param name="HostId">The host it is for.</param>
/// <param name="VaultId">The host's vault at issue.</param>
/// <param name="TargetHost">The hostname as stored, for the audit row.</param>
/// <param name="TargetAddresses">The permitted addresses the name resolved to, in the order to try.</param>
/// <param name="TargetPort">The port.</param>
/// <param name="ExpiresAt">When the ticket stops verifying.</param>
internal sealed record RelayTicket(
Guid TicketId,
Guid SessionId,
Guid UserId,
Guid HostId,
Guid VaultId,
string TargetHost,
IReadOnlyList<IPAddress> TargetAddresses,
int TargetPort,
DateTimeOffset ExpiresAt);
/// <summary>
/// Seals and opens relay tickets.
/// </summary>
/// <remarks>
/// <para>
/// A Data Protection payload, per ADR 0004: authenticated and encrypted, with key rotation handled by
/// the framework, and verifiable on any node that shares the key ring. Encrypted rather than merely
/// signed because the ticket travels in a header every proxy on the way can read, and what it holds is
/// the resolved address of a machine on the operator's network.
/// </para>
/// <para>
/// The expiry is inside the payload and checked against <see cref="TimeProvider"/>, rather than left to
/// the framework's time-limited protector, which reads the system clock directly — the thing this
/// repository bans so that time can be faked in tests.
/// </para>
/// <para>
/// The layout is a version byte and fixed fields, written by hand. Small enough that a serialiser would
/// be the larger half of the code, and a hand layout has no reflection for trimming to break.
/// </para>
/// </remarks>
internal sealed class RelayTicketCodec(IDataProtectionProvider protection, TimeProvider clock)
{
/// <summary>The purpose string, which isolates these payloads from every other protector's.</summary>
private const string Purpose = "DodoSSH.Relay.Ticket";
private const byte LayoutVersion = 1;
/// <summary>More addresses than any real name resolves to, and a bound on what a ticket can carry.</summary>
internal const int MaxAddresses = 16;
private readonly IDataProtector protector = protection.CreateProtector(Purpose);
internal string Seal(RelayTicket ticket)
{
ArgumentNullException.ThrowIfNull(ticket);
using var buffer = new MemoryStream();
using (var writer = new BinaryWriter(buffer))
{
writer.Write(LayoutVersion);
writer.Write(ticket.TicketId.ToByteArray());
writer.Write(ticket.SessionId.ToByteArray());
writer.Write(ticket.UserId.ToByteArray());
writer.Write(ticket.HostId.ToByteArray());
writer.Write(ticket.VaultId.ToByteArray());
writer.Write(ticket.ExpiresAt.ToUnixTimeMilliseconds());
writer.Write(ticket.TargetPort);
writer.Write(ticket.TargetHost);
writer.Write((byte)ticket.TargetAddresses.Count);
foreach (var address in ticket.TargetAddresses)
{
var bytes = address.GetAddressBytes();
writer.Write((byte)bytes.Length);
writer.Write(bytes);
}
}
// Base64url, whose alphabet is all token characters, so it can stand in a subprotocol name.
return WebEncoders.Base64UrlEncode(protector.Protect(buffer.ToArray()));
}
/// <summary>Opens a ticket, or answers null for one that is forged, damaged or expired.</summary>
/// <remarks>
/// One answer for all three, because the caller's remedy is the same — ask for another — and a
/// distinct "expired" would tell a forger which of their attempts had verified.
/// </remarks>
internal RelayTicket? TryOpen(string sealedTicket)
{
byte[] payload;
try
{
payload = protector.Unprotect(WebEncoders.Base64UrlDecode(sealedTicket));
}
catch (Exception exception) when (exception is CryptographicException or FormatException)
{
return null;
}
RelayTicket ticket;
try
{
ticket = Read(payload);
}
catch (Exception exception)
when (exception is EndOfStreamException or ArgumentException or FormatException)
{
return null;
}
return ticket.ExpiresAt > clock.GetUtcNow() ? ticket : null;
}
private static RelayTicket Read(byte[] payload)
{
using var reader = new BinaryReader(new MemoryStream(payload));
if (reader.ReadByte() != LayoutVersion)
{
throw new FormatException("Unknown ticket layout.");
}
var ticketId = new Guid(reader.ReadBytes(16));
var sessionId = new Guid(reader.ReadBytes(16));
var userId = new Guid(reader.ReadBytes(16));
var hostId = new Guid(reader.ReadBytes(16));
var vaultId = new Guid(reader.ReadBytes(16));
var expiresAt = DateTimeOffset.FromUnixTimeMilliseconds(reader.ReadInt64());
var port = reader.ReadInt32();
var host = reader.ReadString();
var count = reader.ReadByte();
if (count is 0 or > MaxAddresses)
{
throw new FormatException("A ticket names between one and sixteen addresses.");
}
var addresses = new IPAddress[count];
for (var index = 0; index < count; index++)
{
var length = reader.ReadByte();
if (length is not (4 or 16))
{
throw new FormatException("An address is four or sixteen bytes.");
}
addresses[index] = new IPAddress(reader.ReadBytes(length));
}
return new RelayTicket(ticketId, sessionId, userId, hostId, vaultId, host, addresses, port, expiresAt);
}
}
@@ -0,0 +1,231 @@
using System.Net;
using System.Net.Sockets;
using DodoSSH.Api.Authorization;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain.Authorization;
using DodoSSH.Infrastructure;
using FastEndpoints;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Relay;
/// <summary>
/// Issues the ticket that lets exactly one relay socket reach exactly one host.
/// </summary>
/// <remarks>
/// <para>
/// Every decision the relay makes is made here, with the full bearer token and the full access model in
/// hand: which host, whether the caller may connect to it, which addresses its name resolves to and
/// which of those the policy permits. The socket that follows decides nothing. It checks that the ticket
/// is genuine, fresh and unused, and dials what the ticket says. See ADR 0004.
/// </para>
/// <para>
/// <b>Every refusal about the target is the same refusal.</b> A host that does not exist, one in a vault
/// the caller cannot reach, one without the relay switched on, one whose name does not resolve, and one
/// whose every address the policy refuses all answer 403 <c>relay-target-rejected</c> with the same
/// sentence. Anyone who can write a host can set its address to anything, so a distinct answer for
/// "resolves, but refused" would turn this endpoint into a scanner for the operator's internal DNS. The
/// real reason goes to the server log, where the operator can read it.
/// </para>
/// </remarks>
internal sealed class RelayTicketEndpoint(
ICurrentUserContext currentUser,
IVaultAccessService vaultAccess,
DodoDbContext database,
IRelayNetwork network,
RelayTicketCodec codec,
RelaySessionRegistry sessions,
IOptions<RelayOptions> options,
TimeProvider clock,
ILogger<RelayTicketEndpoint> logger)
: Endpoint<RelayTicketRequest, Results<Ok<RelayTicketResponse>, ProblemHttpResult>>
{
/// <summary>What every target refusal says, whatever the reason was.</summary>
private const string RejectedDetail =
"The relay will not connect to this host. Check that the relay is switched on for it and that its "
+ "address is one this server may reach; the server's log records which.";
/// <inheritdoc />
public override void Configure()
{
Post(RelayProtocol.TicketsPath);
// Enrolled, matching sync. A relayed host is still a host whose name, username and key are
// ciphertext, so a caller with no identity key has no way to have learned which host to ask for.
Policies(Auth.EnrolledPolicy);
Description(b => b
.WithName("IssueRelayTicket")
.WithSummary("Issues a single-use ticket for one relay connection to one host.")
.WithTags("Relay"));
}
/// <inheritdoc />
public override async Task<Results<Ok<RelayTicketResponse>, ProblemHttpResult>> ExecuteAsync(
RelayTicketRequest req,
CancellationToken ct)
{
var settings = options.Value;
if (!settings.Enabled)
{
return Problems.Coded(
StatusCodes.Status404NotFound,
ProblemCodes.RelayUnavailable,
"This server does not relay. Connect to the host directly; GET /api/v1/meta lists the "
+ "features this server does offer.");
}
var user = await currentUser.GetOrProvisionAsync(ct).ConfigureAwait(false);
var (target, refusal) = await FindTargetAsync(user.Id, req, ct).ConfigureAwait(false);
if (target is not var (hostname, port, vaultId))
{
return Rejected(user.Id, req.HostId, refusal!);
}
// Before resolving rather than after: a caller already at the cap is told so without this server
// spending a DNS query on a ticket that could not be redeemed.
if (!sessions.HasRoomFor(user.Id))
{
RelayLog.LimitReached(logger, user.Id, sessions.Count);
return Problems.Coded(
StatusCodes.Status429TooManyRequests,
ProblemCodes.RelayLimitReached,
"You have as many relay sessions open as this server allows. Close one and try again.");
}
var addresses = await ResolvePermittedAsync(hostname, ct).ConfigureAwait(false);
if (addresses is not { Length: > 0 })
{
return Rejected(user.Id, req.HostId, addresses is null
? "its name did not resolve"
: "every address its name resolved to is refused by policy");
}
var ticket = new RelayTicket(
TicketId: Guid.CreateVersion7(),
SessionId: Guid.CreateVersion7(),
UserId: user.Id,
HostId: req.HostId,
VaultId: vaultId,
TargetHost: hostname,
TargetAddresses: addresses,
TargetPort: port,
ExpiresAt: clock.GetUtcNow() + settings.TicketLifetime);
RelayLog.TicketIssued(logger, user.Id, req.HostId, ticket.SessionId, addresses.Length);
return TypedResults.Ok(new RelayTicketResponse(
Ticket: codec.Seal(ticket),
WebSocketUrl: new Uri(settings.WebSocketUrl!, UriKind.Absolute),
SubProtocol: RelayProtocol.SubProtocol,
SessionId: ticket.SessionId,
ExpiresAt: ticket.ExpiresAt));
}
/// <summary>
/// Finds the host, and decides whether this caller may be relayed to it at all.
/// </summary>
/// <returns>The stored target, or the reason there is none — for the log, never for the caller.</returns>
private async Task<((string Hostname, int Port, Guid VaultId)? Target, string? Refusal)> FindTargetAsync(
Guid userId,
RelayTicketRequest req,
CancellationToken cancellationToken)
{
if (req.PortForwardId is not null)
{
return (null, "a port forward was requested, which v1 does not relay");
}
var host = await database.Hosts
.AsNoTracking()
.SingleOrDefaultAsync(h => h.Id == req.HostId && h.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (host is null)
{
return (null, "no such host");
}
var access = await vaultAccess.ResolveAsync(userId, host.VaultId, cancellationToken).ConfigureAwait(false);
// Connect rather than Read, because Connect is the permission that says what this is. Every role
// that reads today also connects — see VaultAccessService.ForRole — but a role that ever split
// them should be refused a relay here without anybody remembering to look.
if (!access.Granted || !access.Permissions.HasFlag(PermissionFlags.Connect))
{
return (null, "the caller may not connect to this host's vault");
}
// The CHECK constraint makes the second half unreachable while the first is true. Tested anyway,
// because the cost of trusting it is dialling a null.
if (!host.RelayEnabled || host.Hostname is not { } hostname || host.Port is not { } port)
{
return (null, "the relay is not switched on for this host");
}
return RelayAddressPolicy.PermitsPort(port)
? ((hostname, port, host.VaultId), null)
: (null, $"port {port} is refused by policy");
}
/// <summary>
/// Resolves the stored name, and keeps the addresses the policy permits.
/// </summary>
/// <returns>The permitted addresses, possibly none; or null when the name did not resolve.</returns>
/// <remarks>
/// <para>
/// Filtered rather than refused as a whole when one answer is bad. A name that resolves to a public
/// address and a link-local one is dialled on the public address, and never on the other — the
/// ticket carries only what passed, so there is nothing else for the socket to try.
/// </para>
/// <para>
/// The addresses kept are the normalised ones, so what is dialled is exactly what was judged rather
/// than a second spelling of it.
/// </para>
/// </remarks>
private async Task<IPAddress[]?> ResolvePermittedAsync(string hostname, CancellationToken cancellationToken)
{
var policy = new RelayAddressPolicy(options.Value);
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeout.CancelAfter(options.Value.ConnectTimeout);
IPAddress[] resolved;
try
{
resolved = await network.ResolveAsync(hostname, timeout.Token).ConfigureAwait(false);
}
catch (SocketException)
{
return null;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
return
[
.. resolved
.Where(policy.Permits)
.Select(RelayAddressPolicy.Normalise)
.Distinct()
.Take(RelayTicketCodec.MaxAddresses),
];
}
private ProblemHttpResult Rejected(Guid userId, Guid hostId, string reason)
{
RelayLog.TargetRejected(logger, userId, hostId, reason);
return Problems.Coded(StatusCodes.Status403Forbidden, ProblemCodes.RelayTargetRejected, RejectedDetail);
}
}
@@ -73,6 +73,7 @@ internal static class ItemKinds
(IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(), (IItemKind)new HostKind(), new SshKeyKind(), new CredentialKind(), new KnownHostKeyKind(),
new HostGroupKind(), new TagKind(), new SnippetKind(), new HostGroupKind(), new TagKind(), new SnippetKind(),
new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(), new ConnectionLogEntryKind(), new ActivityLogEntryKind(), new ObjectStoreKind(),
new SpelunkKeyKind(),
}.ToDictionary(kind => kind.WireType); }.ToDictionary(kind => kind.WireType);
/// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary> /// <summary>The kind for a wire type, or null when this server does not synchronise it yet.</summary>
@@ -1093,3 +1094,98 @@ internal sealed class ObjectStoreKind : IItemKind
/// <inheritdoc /> /// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null; public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
} }
/// <summary>Spelunk keys: an envelope and nothing else.</summary>
/// <remarks>
/// <see cref="ObjectStoreKind"/>'s rules, because it holds the same class of thing: an API key is a password,
/// and the address beside it is where somebody manages their servers. Nothing on the server calls Spelunk.
/// </remarks>
internal sealed class SpelunkKeyKind : IItemKind
{
/// <inheritdoc />
public SyncEntityType WireType => SyncEntityType.SpelunkKey;
/// <inheritdoc />
public ChangeEntityType ChangeType => ChangeEntityType.SpelunkKey;
/// <inheritdoc />
public async Task<IVaultItem?> FindAsync(
DodoDbContext database,
Guid id,
CancellationToken cancellationToken) =>
await database.SpelunkKeys.SingleOrDefaultAsync(s => s.Id == id, cancellationToken)
.ConfigureAwait(false);
/// <inheritdoc />
public async Task<Dictionary<Guid, IVaultItem>> LoadAsync(
DodoDbContext database,
Guid vaultId,
Guid[] ids,
CancellationToken cancellationToken)
{
var rows = await database.SpelunkKeys
.Where(s => s.VaultId == vaultId && ids.Contains(s.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
return rows.ToDictionary(row => row.Id, row => (IVaultItem)row);
}
/// <inheritdoc />
public IVaultItem Add(DodoDbContext database, Guid id, Guid vaultId)
{
var key = new VaultSpelunkKey { Id = id, VaultId = vaultId };
database.SpelunkKeys.Add(key);
return key;
}
/// <summary>Refuses every plaintext field there is.</summary>
/// <remarks>
/// The relay fields are refused although this type holds an address, as they are for a bucket: the
/// address belongs in the ciphertext, and the server has no business keeping a list of where its users
/// manage their servers from.
/// </remarks>
/// <inheritdoc />
public bool ValidateFields(SyncPlaintextFields fields, out string error)
{
ArgumentNullException.ThrowIfNull(fields);
error = string.Empty;
if (fields.RelayEnabled || fields.Hostname is not null || fields.Port is not null)
{
error = "A Spelunk key is not something the server dials; its address stays encrypted.";
return false;
}
if (fields.GroupId is not null || fields.ParentId is not null || fields.RelatedId is not null)
{
error = "A Spelunk key's contents are inside its payload.";
return false;
}
if (fields.Kind is not null || fields.PublicKeyFingerprint is not null)
{
error = "A Spelunk key carries no plaintext fields at all.";
return false;
}
return true;
}
/// <remarks>Nothing to copy: this type has no plaintext columns to copy anything into.</remarks>
/// <inheritdoc />
public void ApplyFields(IVaultItem item, SyncPlaintextFields fields)
{
}
/// <inheritdoc />
public void ClearFieldsOnDelete(IVaultItem item)
{
}
/// <inheritdoc />
public SyncPlaintextFields? Hydrate(IVaultItem item) => null;
}
+12
View File
@@ -27,4 +27,16 @@ internal static partial class SyncLog
int appliedCount, int appliedCount,
int operationCount, int operationCount,
Guid vaultId); Guid vaultId);
[LoggerMessage(
EventId = 2003,
Level = LogLevel.Information,
Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")]
internal static partial void Collected(ILogger logger, int count);
[LoggerMessage(
EventId = 2004,
Level = LogLevel.Warning,
Message = "Tombstone collection failed; it will run again at the next interval.")]
internal static partial void CollectionFailed(ILogger logger, Exception exception);
} }
+88 -23
View File
@@ -26,12 +26,59 @@ internal sealed class SyncService(
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var afterSequence = 0L; var afterSequence = 0L;
var issuedUnder = 0L;
if (!string.IsNullOrEmpty(request.Cursor) if (!string.IsNullOrEmpty(request.Cursor)
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence)) && !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
{ {
throw new InvalidCursorException(); throw new InvalidCursorException();
} }
// Read before the changes, for the cursor this read hands out; see the check after it.
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
.ConfigureAwait(false);
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
//
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
// a read from the beginning that started after the collection, and its early pages are behind the
// horizon because old, untouched items are; refusing those would send that read back to the start
// for ever. See SyncCursor.
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
{
throw new InvalidCursorException();
}
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
// The horizon from before the read, never the one after: a collection landing between the two
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
// a restart.
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
}
/// <summary>The changes after a position, one page of them, and whether there are more.</summary>
private async Task<(List<VaultChange> Changes, bool HasMore)> ReadPageAsync(
Guid vaultId,
long afterSequence,
SyncPullRequest request,
CancellationToken cancellationToken)
{
var limit = Math.Clamp( var limit = Math.Clamp(
request.Limit ?? options.DefaultPullLimit, request.Limit ?? options.DefaultPullLimit,
1, 1,
@@ -43,7 +90,7 @@ internal sealed class SyncService(
// One extra row, so "is there more" needs no second query. // One extra row, so "is there more" needs no second query.
var query = database.VaultChanges var query = database.VaultChanges
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence); .Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
if (types is not null) if (types is not null)
{ {
@@ -62,18 +109,7 @@ internal sealed class SyncService(
changes.RemoveAt(changes.Count - 1); changes.RemoveAt(changes.Count - 1);
} }
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false); return (changes, hasMore);
// When nothing came back the cursor must not move, or a concurrent write landing between
// this read and the next would be skipped forever.
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
return new SyncPullResponse(
Changes: hydrated,
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
HasMore: hasMore,
ServerTime: clock.GetUtcNow(),
CurrentKeyGeneration: (uint)vault.KeyGeneration);
} }
/// <summary> /// <summary>
@@ -100,9 +136,7 @@ internal sealed class SyncService(
{ {
ValidateBatchLimits(request); ValidateBatchLimits(request);
var strategy = database.Database.CreateExecutionStrategy(); return await database.ExecuteFreshAsync([vault], async () =>
return await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -536,9 +570,21 @@ internal sealed class SyncService(
/// <summary>Attaches current row state to change-log entries.</summary> /// <summary>Attaches current row state to change-log entries.</summary>
/// <remarks> /// <remarks>
/// <para>
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per /// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull /// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from. /// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
/// </para>
/// <para>
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
/// </para>
/// </remarks> /// </remarks>
private async Task<List<Contracts.SyncChange>> HydrateAsync( private async Task<List<Contracts.SyncChange>> HydrateAsync(
Vault vault, Vault vault,
@@ -571,10 +617,13 @@ internal sealed class SyncService(
} }
} }
return [.. changes.Select(change => Describe( return [.. changes
change, .Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
kinds.GetValueOrDefault(change.EntityType), || row.ChangeSequence <= change.Sequence)
rows.GetValueOrDefault((change.EntityType, change.EntityId))))]; .Select(change => Describe(
change,
kinds.GetValueOrDefault(change.EntityType),
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
} }
/// <summary>Turns one change-log entry and its row into what a client receives.</summary> /// <summary>Turns one change-log entry and its row into what a client receives.</summary>
@@ -609,14 +658,30 @@ internal sealed class SyncService(
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary> /// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type)); private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) => /// <remarks>
await database.VaultChanges /// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
/// cost a full resync for nothing.
/// </remarks>
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
{
var head = await database.VaultChanges
.Where(c => c.VaultId == vaultId) .Where(c => c.VaultId == vaultId)
.OrderByDescending(c => c.Sequence) .OrderByDescending(c => c.Sequence)
.Select(c => c.Sequence) .Select(c => c.Sequence)
.FirstOrDefaultAsync(cancellationToken) .FirstOrDefaultAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
}
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
database.Vaults
.Where(v => v.Id == vaultId)
.Select(v => v.SyncHorizon)
.SingleAsync(cancellationToken);
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) => private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
new( new(
operation.OperationId, operation.OperationId,
@@ -0,0 +1,198 @@
using DodoSSH.Api.Setup;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Features.Sync;
/// <summary>
/// Removes tombstones past their retention, and change-log entries a later one has superseded.
/// </summary>
/// <remarks>
/// <para>
/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a
/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every
/// edit ever made to read the items that exist.
/// </para>
/// <para>
/// <b>Superseded entries go at any time.</b> A pull returns entries after a cursor in order, so a client
/// before both an item's old entry and its new one reads the new one, and a client between them already has
/// the old one. Nothing reads history: a conflict resolves against the newest entry alone.
/// </para>
/// <para>
/// <b>Tombstones go after the retention</b>, and taking them is the one change here a client can notice: a
/// machine that last synced before the deletion would never hear of it. So each vault records the highest
/// sequence collected from it — <see cref="Vault.SyncHorizon"/> — and a pull from a cursor below that is
/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer
/// has; see the client's <c>SyncEngine</c>.
/// </para>
/// <para>
/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to
/// the vault — and the items are read again under it, so one restored since it was found is left alone.
/// </para>
/// </remarks>
internal sealed class TombstoneCollector(
DodoDbContext database,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollector> logger)
{
/// <summary>Collects what is due, once.</summary>
/// <returns>How many tombstones and superseded entries went.</returns>
internal async Task<int> RunOnceAsync(CancellationToken cancellationToken)
{
var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays);
var collected = 0;
collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false);
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
SyncLog.Collected(logger, collected);
return collected;
}
private async Task<int> CollectAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var due = await table(database)
.Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before)
.Select(item => new { item.VaultId, item.Id })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var collected = 0;
foreach (var vault in due.GroupBy(item => item.VaultId))
{
var ids = vault.Select(item => item.Id).ToArray();
collected += await database.ExecuteFreshAsync(
[],
() => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken))
.ConfigureAwait(false);
}
return collected;
}
private async Task<int> CollectFromVaultAsync<T>(
Func<DodoDbContext, DbSet<T>> table,
ChangeEntityType type,
Guid vaultId,
Guid[] ids,
DateTimeOffset before,
CancellationToken cancellationToken)
where T : class, IVaultItem
{
var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
await database.Database
.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken)
.ConfigureAwait(false);
// Read again under the lock: an item restored since it was found is no longer due.
var still = await table(database)
.Where(item => item.VaultId == vaultId
&& ids.Contains(item.Id)
&& item.DeletedAtUtc != null
&& item.DeletedAtUtc < before)
.Select(item => new { item.Id, item.ChangeSequence })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
if (still.Count == 0)
{
return 0;
}
var going = still.Select(item => item.Id).ToArray();
var horizon = still.Max(item => item.ChangeSequence);
await table(database)
.Where(item => item.VaultId == vaultId && going.Contains(item.Id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.VaultChanges
.Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await database.Vaults
.Where(v => v.Id == vaultId && v.SyncHorizon < horizon)
.ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken)
.ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
return still.Count;
}
/// <summary>Removes every change-log entry a later entry for the same item has superseded.</summary>
private Task<int> CompactAsync(CancellationToken cancellationToken) =>
database.Database.ExecuteSqlRawAsync(
"DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older "
+ "USING " + DodoDbContext.SchemaName + ".sync_change AS newer "
+ "WHERE newer.vault_id = older.vault_id "
+ "AND newer.entity_type = older.entity_type "
+ "AND newer.entity_id = older.entity_id "
+ "AND newer.sequence > older.sequence",
cancellationToken);
}
/// <summary>Runs the <see cref="TombstoneCollector"/> on a schedule, in a scope of its own each time.</summary>
internal sealed class TombstoneCollectionService(
IServiceScopeFactory scopes,
IOptions<SyncOptions> syncOptions,
TimeProvider clock,
ILogger<TombstoneCollectionService> logger) : BackgroundService
{
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false);
var scope = scopes.CreateAsyncScope();
await using var _ = scope.ConfigureAwait(false);
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>()
.RunOnceAsync(stoppingToken)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// The next run tries again; nothing collected late is harmed by waiting.
SyncLog.CollectionFailed(logger, exception);
}
}
}
}
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
} }
// Owner, not admin. An admin the owner promoted must not be able to archive the team out // Owner, not admin. An admin the owner promoted must not be able to archive the team out
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this // from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
// re-checks it, unlike the transfer below, so this line is the whole of the guard. // checks it again, because deleting a team's last vault archives it too.
if (!access.IsOwner) if (!access.IsOwner)
{ {
return Problems.Coded( return Problems.Coded(
@@ -0,0 +1,27 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Features.Teams;
/// <summary>
/// Serialises the writes that decide what a team owns, for the life of the transaction.
/// </summary>
/// <remarks>
/// <para>
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
/// </para>
/// <para>
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
/// </para>
/// </remarks>
internal static class TeamLock
{
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
database.Database.ExecuteSqlAsync(
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
cancellationToken);
}
+22 -16
View File
@@ -1,5 +1,6 @@
using System.Globalization; using System.Globalization;
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Domain; using DodoSSH.Domain;
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
@@ -244,28 +245,37 @@ internal sealed class TeamService(
Team team, Team team,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
ArgumentNullException.ThrowIfNull(actor);
ArgumentNullException.ThrowIfNull(team); ArgumentNullException.ThrowIfNull(team);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false); // Both callers check this first and answer the caller properly; this is what stops a third one that
// forgets from letting an admin archive the team out from under its owner.
if (vaultCount > 0) if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
{ {
throw new TeamNotEmptyException( throw new InvalidOperationException("Only the owner of a team can archive it.");
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
} }
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy(); var archived = await database.ExecuteFreshAsync([team], async () =>
var archived = await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false); await using var _ = transaction.ConfigureAwait(false);
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
if (vaultCount > 0)
{
throw new TeamNotEmptyException(
string.Create(
CultureInfo.InvariantCulture,
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
}
var memberships = await database.TeamMemberships var memberships = await database.TeamMemberships
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null) .Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken) .ToListAsync(cancellationToken)
@@ -335,9 +345,7 @@ internal sealed class TeamService(
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken) var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
var strategy = database.Database.CreateExecutionStrategy(); await database.ExecuteFreshAsync([incoming, outgoing], async () =>
await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -716,9 +724,7 @@ internal sealed class TeamService(
} }
var now = clock.GetUtcNow(); var now = clock.GetUtcNow();
var strategy = database.Database.CreateExecutionStrategy(); var revoked = await database.ExecuteFreshAsync([membership], async () =>
var revoked = await strategy.ExecuteAsync(async () =>
{ {
var transaction = await database.Database var transaction = await database.Database
.BeginTransactionAsync(cancellationToken) .BeginTransactionAsync(cancellationToken)
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
{ {
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false); var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
if (orphaned is not null) // Archived only by its owner. Archiving revokes every membership, the owner's included, and an
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
// the owner, who can archive it or make a vault in it.
if (orphaned is not null
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
{ {
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false); await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
} }
@@ -1,5 +1,6 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Setup;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Domain; using DodoSSH.Domain;
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
"That vault id is already in use. Generate a new UUIDv7 and retry."); "That vault id is already in use. Generate a new UUIDv7 and retry.");
} }
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow()); await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id); TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
RekeyRequired: false); RekeyRequired: false);
} }
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
private Task WriteVaultAsync(
UserAccount user,
Team team,
CreateTeamVaultRequest request,
string name,
UserKey key,
CancellationToken cancellationToken) =>
database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.ConfigureAwait(false);
await using var _ = transaction.ConfigureAwait(false);
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
try
{
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
{
// The pre-check covers the ordinary case; this is the race between two creates choosing
// the same id, which must not surface as a 500 about a constraint.
throw new TeamInvalidException(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
});
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
{
var live = await database.Teams
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
.ConfigureAwait(false);
if (!live)
{
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
}
}
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary> /// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
private void AddVaultWithSelfGrant( private void AddVaultWithSelfGrant(
UserAccount user, UserAccount user,
+13 -6
View File
@@ -41,17 +41,24 @@ builder.Services.AddScoped<VaultGrantService>();
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>(); builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>(); builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice // A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the // whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
// write paths that announce a change need only the two methods that announce one, and should not gain // that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
// a reference to connection management to get them. // vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
builder.Services.AddSingleton<VaultEventHub>(); builder.Services.AddSingleton<VaultEventHub>();
builder.Services.AddSingleton<IVaultEventPublisher>( builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
provider => provider.GetRequiredService<VaultEventHub>());
// ADR 0003's tombstone collection, and the compaction of the change log that goes with it.
builder.Services.AddScoped<TombstoneCollector>();
builder.Services.AddHostedService<TombstoneCollectionService>();
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
builder.Services.AddDodoRelay();
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's // Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
// DbContext, and a singleton would capture one for the lifetime of the process. // DbContext, and a singleton would capture one for the lifetime of the process.
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>(); builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
// Replaces the framework default, which is also registered as a singleton. Last registration wins. // Replaces the framework default, which is also registered as a singleton. Last registration wins.
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>(); builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
+9 -2
View File
@@ -62,7 +62,14 @@ internal static class Auth
private static void AddDodoPolicies(AuthorizationOptions options) private static void AddDodoPolicies(AuthorizationOptions options)
{ {
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser()); // Both policies also require the account to be active. A suspended or deprovisioned account still
// holds a token the identity provider will vouch for until it expires — the status is this server's
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
options.AddPolicy(
AuthenticatedPolicy,
policy => policy
.RequireAuthenticatedUser()
.AddRequirements(new Authorization.ActiveAccountRequirement()));
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler. // Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying // An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
@@ -72,7 +79,7 @@ internal static class Auth
EnrolledPolicy, EnrolledPolicy,
policy => policy policy => policy
.RequireAuthenticatedUser() .RequireAuthenticatedUser()
.AddRequirements(new Authorization.EnrolledRequirement())); .AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
// Deny by default: an endpoint without an explicit policy still requires a caller. // Deny by default: an endpoint without an explicit policy still requires a caller.
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy); options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
+27 -3
View File
@@ -54,6 +54,13 @@ internal static class Configuration
+ "setting exists to provide.") + "setting exists to provide.")
.ValidateOnStart(); .ValidateOnStart();
services.AddRelayOptions();
return services;
}
/// <summary>The relay's options, which carry more rules than any other section. See ADR 0004.</summary>
private static void AddRelayOptions(this IServiceCollection services) =>
services.AddOptions<RelayOptions>() services.AddOptions<RelayOptions>()
.BindConfiguration(RelayOptions.SectionName) .BindConfiguration(RelayOptions.SectionName)
.ValidateDataAnnotations() .ValidateDataAnnotations()
@@ -63,11 +70,28 @@ internal static class Configuration
.Validate( .Validate(
options => options.IdleTimeout < options.MaxSessionDuration, options => options.IdleTimeout < options.MaxSessionDuration,
"Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.") "Relay:IdleTimeout must be shorter than Relay:MaxSessionDuration.")
.Validate(
options => options.TicketLifetime > TimeSpan.Zero
&& options.TicketLifetime <= TimeSpan.FromSeconds(30),
"Relay:TicketLifetime must be positive and at most 30 seconds. A ticket is a bearer "
+ "credential in a header; ADR 0004 bounds how long one may be worth stealing.")
.Validate(
options => options.ConnectTimeout > TimeSpan.Zero && options.DrainTimeout >= TimeSpan.Zero,
"Relay:ConnectTimeout must be positive and Relay:DrainTimeout must not be negative.")
.Validate(
options => Features.Relay.RelayAddressPolicy.AreNetworks(options.AllowedNetworks)
&& Features.Relay.RelayAddressPolicy.AreNetworks(options.DeniedNetworks),
"Relay:AllowedNetworks and Relay:DeniedNetworks must hold CIDR networks such as "
+ "10.20.0.0/16, with no host bits set. An entry this server cannot read would be a gap "
+ "the operator believes is closed.")
.Validate(
options => !options.Enabled
|| (Uri.TryCreate(options.WebSocketUrl, UriKind.Absolute, out var url)
&& (string.Equals(url.Scheme, Uri.UriSchemeWs, StringComparison.Ordinal)
|| string.Equals(url.Scheme, Uri.UriSchemeWss, StringComparison.Ordinal))),
"Relay:WebSocketUrl must be an absolute ws:// or wss:// URL.")
.ValidateOnStart(); .ValidateOnStart();
return services;
}
/// <summary> /// <summary>
/// Warns about configuration that is individually valid but dangerous in combination. /// Warns about configuration that is individually valid but dangerous in combination.
/// </summary> /// </summary>
+38 -7
View File
@@ -121,17 +121,44 @@ public sealed class RelayOptions
/// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all. /// the ACL: only hosts in a vault the caller holds Connect on can be dialled at all.
/// </para> /// </para>
/// <para> /// <para>
/// <b>Nothing reads this setting yet, and that is the point of saying so here.</b> The relay is /// Governs the private ranges only — RFC1918, carrier-grade NAT (which is where a Tailnet lives) and
/// an ADR and a contract; there is no endpoint and no dialler. This option exists so the shape /// IPv6 unique-local — and never the list <c>RelayAddressPolicy</c> refuses with no configuration
/// is settled before the code is, which makes it exactly the kind of comment that gets read as a /// escape: loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses.
/// description of an implemented control. It is not one. Whoever builds the dialler owes ADR 0004 /// Nothing here can re-open those, which is what ADR 0004 §3 means by non-overridable.
/// §3 in full — loopback, link-local, <c>0.0.0.0/8</c>, multicast and the cloud metadata addresses
/// denied with no configuration escape, and IPv4-mapped IPv6 normalised and re-checked — and this
/// flag governs RFC1918 only, never that list.
/// </para> /// </para>
/// </remarks> /// </remarks>
public bool AllowPrivateNetworks { get; set; } = true; public bool AllowPrivateNetworks { get; set; } = true;
/// <summary>
/// Networks, in CIDR form, that a relay target must fall inside. Empty means any network the other
/// rules permit.
/// </summary>
/// <remarks>
/// When set, this is the whole answer for the ranges it names: a target inside one is permitted even
/// with <see cref="AllowPrivateNetworks"/> off, because "only 10.20.0.0/16" is the precise thing an
/// operator means by setting both. It still cannot reach the non-overridable list, and
/// <see cref="DeniedNetworks"/> still wins over it.
/// </remarks>
public IList<string> AllowedNetworks { get; } = [];
/// <summary>Networks, in CIDR form, that a relay target may never fall inside.</summary>
/// <remarks>
/// Checked before <see cref="AllowedNetworks"/>, so a deny inside an allow is a hole in it — the way
/// round that lets an operator carve the database subnet out of an otherwise permitted range.
/// </remarks>
public IList<string> DeniedNetworks { get; } = [];
/// <summary>
/// A directory to keep the ticket key ring in. Unset means the platform default.
/// </summary>
/// <remarks>
/// Losing the key ring is harmless in the way losing <c>Sync:CursorSigningKey</c> is: a ticket lives
/// thirty seconds, so a restart invalidates at most the connects in flight and the client asks
/// again. What does need it is more than one node — every node must be able to read every other's
/// tickets for round-robin to work, so a multi-node deployment points this at shared storage.
/// </remarks>
public string? KeyRingDirectory { get; set; }
/// <summary>Maximum concurrent sessions per user.</summary> /// <summary>Maximum concurrent sessions per user.</summary>
[Range(1, 1000)] [Range(1, 1000)]
public int MaxConcurrentSessionsPerUser { get; set; } = 10; public int MaxConcurrentSessionsPerUser { get; set; } = 10;
@@ -262,6 +289,10 @@ public sealed class SyncOptions
[Range(1, 3650)] [Range(1, 3650)]
public int TombstoneRetentionDays { get; set; } = 90; public int TombstoneRetentionDays { get; set; } = 90;
/// <summary>How often the tombstone collector runs, in minutes.</summary>
[Range(1, 7 * 24 * 60)]
public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60;
/// <summary> /// <summary>
/// Key used to sign sync cursors, base64. /// Key used to sign sync cursors, base64.
/// </summary> /// </summary>
@@ -1,6 +1,7 @@
using DodoSSH.Api.Features.Events; using DodoSSH.Api.Features.Events;
using DodoSSH.Api.Features.Identity; using DodoSSH.Api.Features.Identity;
using DodoSSH.Api.Features.Meta; using DodoSSH.Api.Features.Meta;
using DodoSSH.Api.Features.Relay;
using DodoSSH.Api.Features.Sync; using DodoSSH.Api.Features.Sync;
using DodoSSH.Api.Features.Teams; using DodoSSH.Api.Features.Teams;
using DodoSSH.Contracts; using DodoSSH.Contracts;
@@ -45,6 +46,8 @@ internal static class EndpointRegistration
typeof(SyncPullEndpoint), typeof(SyncPullEndpoint),
typeof(SyncPushEndpoint), typeof(SyncPushEndpoint),
typeof(VaultEventsEndpoint), typeof(VaultEventsEndpoint),
typeof(RelayTicketEndpoint),
typeof(RelayConnectEndpoint),
typeof(CreateTeamEndpoint), typeof(CreateTeamEndpoint),
typeof(ListTeamsEndpoint), typeof(ListTeamsEndpoint),
typeof(UpdateTeamEndpoint), typeof(UpdateTeamEndpoint),
@@ -65,7 +68,6 @@ internal static class EndpointRegistration
// Registered as each feature lands: // Registered as each feature lands:
// Identity — key rotation, passphrase change // Identity — key rotation, passphrase change
// Vaults — per-item ACLs // Vaults — per-item ACLs
// Relay — tickets and the WebSocket
// Audit, Admin // Audit, Admin
}); });
+72
View File
@@ -0,0 +1,72 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace DodoSSH.Api.Setup;
/// <summary>
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
/// rather than from what the failed attempt left in the change tracker.
/// </summary>
/// <remarks>
/// <para>
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
/// its two role changes, each reporting success.
/// </para>
/// <para>
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
/// before: nothing is cleared or reloaded until something has failed.
/// </para>
/// </remarks>
internal static class FreshAttempts
{
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
/// <param name="database">The request's context.</param>
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
/// <param name="attempt">The transaction, which begins and commits its own.</param>
internal static Task<T> ExecuteFreshAsync<T>(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task<T>> attempt)
{
ArgumentNullException.ThrowIfNull(database);
ArgumentNullException.ThrowIfNull(carried);
ArgumentNullException.ThrowIfNull(attempt);
var strategy = database.Database.CreateExecutionStrategy();
var attempts = 0;
return strategy.ExecuteAsync(async () =>
{
if (attempts++ > 0)
{
database.ChangeTracker.Clear();
foreach (var entity in carried)
{
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
}
}
return await attempt().ConfigureAwait(false);
});
}
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
internal static Task ExecuteFreshAsync(
this DodoDbContext database,
IReadOnlyList<object> carried,
Func<Task> attempt) =>
database.ExecuteFreshAsync(
carried,
async () =>
{
await attempt().ConfigureAwait(false);
return true;
});
}
+10 -5
View File
@@ -1,5 +1,6 @@
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Npgsql; using Npgsql;
@@ -29,14 +30,18 @@ internal static class Persistence
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
$"ConnectionStrings:{ConnectionStringName} is not configured."); $"ConnectionStrings:{ConnectionStringName} is not configured.");
services.AddDbContext<DodoDbContext>(options => options services.AddDbContext<DodoDbContext>((provider, options) => options
.UseNpgsql(connectionString, npgsql => npgsql .UseNpgsql(connectionString, npgsql => npgsql
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName) .MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
// Retry on transient faults only. Note this is safe here because writes go // Retry on transient faults only. A write transaction replayed by the strategy runs on
// through explicitly-managed transactions rather than relying on the execution // the same context, which still holds what the failed attempt tracked — every such
// strategy to replay ambient ones. // transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
// the database instead.
.EnableRetryOnFailure(3)) .EnableRetryOnFailure(3))
.UseSnakeCaseNamingConvention()); .UseSnakeCaseNamingConvention()
// None in production. The seam a test uses to make a commit fail the way a dropped
// connection would, which is the only way to watch a retry happen.
.AddInterceptors(provider.GetServices<IInterceptor>()));
services.AddHealthChecks() services.AddHealthChecks()
.AddCheck<DatabaseHealthCheck>( .AddCheck<DatabaseHealthCheck>(
+60
View File
@@ -0,0 +1,60 @@
using DodoSSH.Api.Features.Relay;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.DataProtection.KeyManagement;
using Microsoft.AspNetCore.DataProtection.Repositories;
using Microsoft.Extensions.Options;
namespace DodoSSH.Api.Setup;
/// <summary>The relay's services. See ADR 0004.</summary>
internal static class RelaySetup
{
/// <summary>
/// How much longer than the drain the host waits before it stops regardless.
/// </summary>
/// <remarks>
/// Room for the close handshakes and the audit rows' final writes once the drain has run out, which
/// is the part of a shutdown an operator would otherwise lose without noticing.
/// </remarks>
private static readonly TimeSpan ShutdownMargin = TimeSpan.FromSeconds(10);
internal static IServiceCollection AddDodoRelay(this IServiceCollection services)
{
// Singletons: a session outlives the request that opened it, a protector is safe to share, and
// the network seam holds no state.
services.AddSingleton<RelayTicketCodec>();
services.AddSingleton<RelaySessionRegistry>();
services.AddSingleton<RelayAccessWatch>();
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
// The application name isolates this deployment's payloads from any other ASP.NET application
// sharing a key directory, and — unlike the default, which is derived from the content root —
// is the same on every node, which is what lets any node verify any node's ticket.
services.AddDataProtection().SetApplicationName("DodoSSH");
services.AddOptions<KeyManagementOptions>()
.Configure<IOptions<RelayOptions>, ILoggerFactory>((keys, relay, loggers) =>
{
if (relay.Value.KeyRingDirectory is { Length: > 0 } directory)
{
keys.XmlRepository = new FileSystemXmlRepository(new DirectoryInfo(directory), loggers);
}
});
// Longer than the drain, or the host would stop waiting for live sessions before the drain ADR 0004
// promises them had run. Harmless with the relay off: nothing else holds a request open that long,
// and the host stops as soon as the last request has.
services.AddOptions<HostOptions>()
.Configure<IOptions<RelayOptions>>((host, relay) =>
{
var wanted = relay.Value.DrainTimeout + ShutdownMargin;
if (wanted > host.ShutdownTimeout)
{
host.ShutdownTimeout = wanted;
}
});
return services;
}
}
+7 -1
View File
@@ -132,7 +132,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
var keepAlive = new SessionKeepAlive( var keepAlive = new SessionKeepAlive(
workspace, workspace,
activeTransfers: () => viewModel.Transfers.ActiveTransfers, activeTransfers: () => viewModel.Transfers.ActiveTransfers,
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession); holdsFileSession: () => viewModel.Transfers.HasLiveFileSession,
listeningForwards: () => viewModel.ListeningForwardCount);
// The other end of the same wire: the workspace announces its own sessions ending, and the queue // The other end of the same wire: the workspace announces its own sessions ending, and the queue
// announces transfers appearing and finishing, and a Files session connecting or disconnecting. // announces transfers appearing and finishing, and a Files session connecting or disconnecting.
@@ -148,6 +149,11 @@ public sealed partial class DodoSshApp : Avalonia.Application
// in the first place, which was precisely the promise this whole arrangement exists to keep. // in the first place, which was precisely the promise this whole arrangement exists to keep.
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh(); viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
// A forward starting, stopping or failing changes the notification's wording and nothing else — it
// rides a shell's connection, so the shell is already what holds the service up. The user is still
// entitled to know a phone in their pocket is holding ports open.
viewModel.ForwardsChanged += (_, _) => keepAlive.Refresh();
keepAlive.Refresh(); keepAlive.Refresh();
} }
@@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service
/// <param name="liveSessions">Shells with a live channel behind them.</param> /// <param name="liveSessions">Shells with a live channel behind them.</param>
/// <param name="activeTransfers">Transfers still moving bytes.</param> /// <param name="activeTransfers">Transfers still moving bytes.</param>
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param> /// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
/// <param name="listeningForwards">
/// Port forwards listening over those shells. Named in the summary and nothing else: a forward rides a
/// shell's connection, so it cannot be the reason the process has to stay up — see
/// <see cref="SessionKeepAlive"/>.
/// </param>
/// <remarks> /// <remarks>
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as /// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
/// always. Something live and the service not yet running starts it. Something live and the service /// always. Something live and the service not yet running starts it. Something live and the service
@@ -152,7 +157,7 @@ internal sealed class SessionForegroundService : Service
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real /// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
/// start even over a service that looks like it is running; the inline remark below is why. /// start even over a service that looks like it is running; the inline remark below is why.
/// </remarks> /// </remarks>
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession) public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{ {
var context = PhoneEnvironment.Require(); var context = PhoneEnvironment.Require();
@@ -165,7 +170,7 @@ internal sealed class SessionForegroundService : Service
// The summary says what is actually held, counted rather than generic — the same principle the // The summary says what is actually held, counted rather than generic — the same principle the
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on. // delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession); var summary = Summarise(liveSessions, activeTransfers, holdsFileSession, listeningForwards);
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is // In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
// always allowed and is preferred even when `running` says the service is up: a disconnect followed // always allowed and is preferred even when `running` says the service is up: a disconnect followed
@@ -255,15 +260,22 @@ internal sealed class SessionForegroundService : Service
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode)); activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
} }
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession) private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{ {
var parts = new List<string>(3); var parts = new List<string>(4);
if (liveSessions > 0) if (liveSessions > 0)
{ {
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected"); parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
} }
// Beside the shells they run over, and only while one is: a count left over from a session that has
// just ended would be a receipt for ports nothing holds open any more.
if (liveSessions > 0 && listeningForwards > 0)
{
parts.Add(listeningForwards == 1 ? "1 port forwarded" : $"{listeningForwards} ports forwarded");
}
if (holdsFileSession) if (holdsFileSession)
{ {
parts.Add("Files connected"); parts.Add("Files connected");
@@ -24,12 +24,20 @@ namespace DodoSSH.Client.Android.Platform;
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that /// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
/// gap closed, read the same way the other two facts are: asked, not cached. /// gap closed, read the same way the other two facts are: asked, not cached.
/// </para> /// </para>
/// <para>
/// Listening port forwards are a fourth fact, and a different kind: they only change what the notification
/// says. A forward runs over a shell's connection and stops with it, so there is never a listening forward
/// without a live shell already holding the service up — and a count that could start the service on its own
/// would only ever be wrong, in the moment a forward's Stopped had not been heard yet. What they add is the
/// receipt: the user is entitled to know the phone is holding ports open, as well as shells.
/// </para>
/// </remarks> /// </remarks>
internal sealed class SessionKeepAlive : IDisposable internal sealed class SessionKeepAlive : IDisposable
{ {
private readonly TerminalWorkspace workspace; private readonly TerminalWorkspace workspace;
private readonly Func<int> activeTransfers; private readonly Func<int> activeTransfers;
private readonly Func<bool> holdsFileSession; private readonly Func<bool> holdsFileSession;
private readonly Func<int> listeningForwards;
/// <param name="workspace">The live shells.</param> /// <param name="workspace">The live shells.</param>
/// <param name="activeTransfers"> /// <param name="activeTransfers">
@@ -40,11 +48,20 @@ internal sealed class SessionKeepAlive : IDisposable
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but- /// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>. /// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
/// </param> /// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession) /// <param name="listeningForwards">
/// How many port forwards are listening, across every tab. For the notification's wording only; see the
/// class remarks. See <c>MainWindowViewModel.ListeningForwardCount</c>.
/// </param>
public SessionKeepAlive(
TerminalWorkspace workspace,
Func<int> activeTransfers,
Func<bool> holdsFileSession,
Func<int> listeningForwards)
{ {
this.workspace = workspace; this.workspace = workspace;
this.activeTransfers = activeTransfers; this.activeTransfers = activeTransfers;
this.holdsFileSession = holdsFileSession; this.holdsFileSession = holdsFileSession;
this.listeningForwards = listeningForwards;
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the // Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
// closer's own — which is fine: starting and stopping a service is a binder call and needs no // closer's own — which is fine: starting and stopping a service is a binder call and needs no
@@ -56,14 +73,15 @@ internal sealed class SessionKeepAlive : IDisposable
/// <summary>Re-reads the counts and starts or stops the service to match.</summary> /// <summary>Re-reads the counts and starts or stops the service to match.</summary>
/// <remarks> /// <remarks>
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a /// Called after anything that could change any of the facts — opening a shell, closing a tab, a transfer
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free: /// finishing, a Files connection opening or closing, a forward starting or stopping. Calling it when nothing changed is free:
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or, /// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one, /// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
/// and Android treats all of those as no-ops. /// and Android treats all of those as no-ops.
/// </remarks> /// </remarks>
public void Refresh() => public void Refresh() =>
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession()); SessionForegroundService.Reconcile(
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() public void Dispose()
@@ -72,7 +90,7 @@ internal sealed class SessionKeepAlive : IDisposable
// The notification goes with the composition root. Leaving it up over a process that is shutting // The notification goes with the composition root. Leaving it up over a process that is shutting
// down is how an SSH client acquires a reputation for a notification you cannot get rid of. // down is how an SSH client acquires a reputation for a notification you cannot get rid of.
SessionForegroundService.Reconcile(0, 0, false); SessionForegroundService.Reconcile(0, 0, false, 0);
} }
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh(); private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
@@ -1002,7 +1002,8 @@
FormatString="0" ShowButtonSpinner="False" MinHeight="44" FormatString="0" ShowButtonSpinner="False" MinHeight="44"
PlaceholderText="{Binding EditorPortPlaceholder}" /> PlaceholderText="{Binding EditorPortPlaceholder}" />
<TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}" <TextBox Grid.Column="2" Classes="field" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" /> PlaceholderText="{Binding EditorUsernamePlaceholder}"
IsEnabled="{Binding EditorAsksForLogin}" />
</Grid> </Grid>
<TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" /> <TextBox Classes="field" Text="{Binding EditorNotes}" PlaceholderText="notes" />
@@ -1016,6 +1017,7 @@
<TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" /> <TextBlock Classes="label" Text="AUTHENTICATION" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}" <ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}" SelectedItem="{Binding EditorSelectedAuthentication}"
IsEnabled="{Binding EditorAsksForLogin}"
HorizontalAlignment="Stretch" MinHeight="44"> HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice"> <DataTemplate x:DataType="vm:AuthenticationChoice">
@@ -1028,6 +1030,9 @@
</DataTemplate> </DataTemplate>
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<!-- Why the login is greyed out, as on the desktop. See VaultViewModel.EditorAsksForLogin. -->
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !EditorAsksForLogin}"
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
<!-- <!--
Making a credential without leaving the host, as on the desktop and on the same reasoning: the Making a credential without leaving the host, as on the desktop and on the same reasoning: the
@@ -1199,19 +1204,53 @@
port into plaintext columns the server can read, which is the single deliberate concession in port into plaintext columns the server can read, which is the single deliberate concession in
the design — see ADR 0004 — so it says so rather than being a switch labelled "relay". the design — see ADR 0004 — so it says so rather than being a switch labelled "relay".
◆ AND IT SAYS, FIRST, THAT IT BUYS NOTHING TODAY. The relay's server half is built and this It also says the two things a user would otherwise find out by failing: the server, not this
client has no path to it — the connect path dials the address directly whether this is ticked or phone, has to be able to reach the address, and a relayed host does not fall back to dialling
not — so the box as it stood spent that concession and delivered nothing for it. Left tickable directly, so it connects only while the server is reachable. See
rather than disabled, so a host already carrying the flag can lose it. See
docs/reaching-a-host-you-cannot-dial.md. docs/reaching-a-host-you-cannot-dial.md.
--> -->
<CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44"> <CheckBox IsChecked="{Binding EditorRelayEnabled}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5" <TextBlock Classes="mono" FontSize="11.5"
Text="Reach this host through the server relay (not wired up yet)" Text="Reach this host through the server relay"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</CheckBox> </CheckBox>
<TextBlock Classes="body" <TextBlock Classes="body"
Text="Not built yet: this app always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it works, the relay will dial on your behalf — which is why the address and port have to be stored in the clear. Everything else about the host stays encrypted either way, and a relayed host needs a port of its own rather than its group's." /> Text="The server dials this host on your behalf, for a machine this phone cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in the clear; everything else about the host stays encrypted. A relayed host needs a port of its own rather than its group's, and connects only while you are online." />
<!--
The relay's alternative, and the desktop drawer's Spelunk card in this head's controls: ticking
one unticks the other. The key is a keychain credential chosen here, never typed here. See
VaultViewModel.Spelunk.cs.
-->
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5"
Text="Reach this host through Spelunk"
TextWrapping="Wrap" />
</CheckBox>
<StackPanel Spacing="8" IsVisible="{Binding EditorThroughSpelunk}">
<TextBox Classes="field" Text="{Binding EditorSpelunkApi}"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Classes="field" Text="{Binding EditorSpelunkServerId}"
PlaceholderText="Spelunk server id" />
<TextBlock Classes="detail" Text="Spelunk API key" />
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
SelectedItem="{Binding EditorSelectedSpelunkKey}"
HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Label}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!-- The desktop card's sign-in box. See SpelunkRoute.SignInThroughSpelunk. -->
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" MinHeight="44">
<TextBlock Classes="mono" FontSize="11.5"
Text="Sign in through Spelunk (no SSH key needed)"
TextWrapping="Wrap" />
</CheckBox>
</StackPanel>
<TextBlock Classes="body"
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Choose a Spelunk API key (Admin role) saved from the desktop's Settings → Security, or a credential whose password is the key; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. Connects only while Spelunk and its agent are reachable." />
<!-- <!--
This page covers the whole screen while it is open, which is why it needs a Status line of its This page covers the whole screen while it is open, which is why it needs a Status line of its
@@ -189,14 +189,18 @@
◆ Port forwarding, stated rather than drawn. ◆ Port forwarding, stated rather than drawn.
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
rules with toggles and byte counters. Nothing in this application forwards anything — ISshConnection rules with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
offers OpenShellAsync and nothing else — so every switch on that screen would be a control with no forwards start with every terminal opened to it — but it is not a destination, so it has no row. A
effect and every counter a number with no source. forward belongs to a host and runs with a session, and on this head the session's forwards are a
sheet off the terminal's bar: the ⇄ button, drawn for a tab that has some, lists them with a switch
each. See MainWindowViewModel.IsForwardsSheetOpen.
What the phone lacks is the editor: its host form has no PORT FORWARDS section yet, so forwards are
added on the desktop and arrive here with the host. The byte counters are not built anywhere;
SSH.NET keeps no count to draw.
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail: It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
a destination that is quietly absent reads as a product that never intended it, and this one is a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md.
intended. SyncEntityType.PortForward = 9 is reserved on the wire and unused. See
docs/design-import-gaps.md.
--> -->
<Border Classes="card" Margin="6,16,6,0"> <Border Classes="card" Margin="6,16,6,0">
<StackPanel Spacing="7"> <StackPanel Spacing="7">
@@ -206,11 +210,11 @@
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold" <TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
Foreground="{StaticResource TextDim}" Text="Port forwarding" /> Foreground="{StaticResource TextDim}" Text="Port forwarding" />
<Border Classes="tag outline" VerticalAlignment="Center"> <Border Classes="tag outline" VerticalAlignment="Center">
<TextBlock Text="NOT BUILT" /> <TextBlock Text="IN THE TERMINAL" />
</Border> </Border>
</StackPanel> </StackPanel>
<TextBlock Classes="body" <TextBlock Classes="body"
Text="A connection here opens one channel — a shell — and nothing else, so there is no tunnel for a rule to run through. The wire protocol reserves a slot for forwarding rules and no client writes one. Nothing on this screen is hidden by a setting; the feature is absent." /> Text="Forwards saved on a host start with every terminal opened to it, here as on the desktop. The ⇄ button in the terminal's bar lists them, with a switch each and the reason one could not start. They are added and edited in the desktop app's host editor; this phone runs them but has no editor for them yet." />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -579,7 +579,7 @@ internal sealed partial class PhoneShell : UserControl
/// <para> /// <para>
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now /// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way /// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
/// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the /// off it other than the scrim and CANCEL. The forwards sheet, raised from the same bar, shares the guard. It is checked before the terminal is dismissed for the
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a /// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
/// menu would take two, neither of them the one being looked at. /// menu would take two, neither of them the one being looked at.
/// </para> /// </para>
@@ -598,13 +598,12 @@ internal sealed partial class PhoneShell : UserControl
return; return;
} }
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone // The connect menu or the forwards sheet, which are raised from the terminal's own bar and are the
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the // topmost thing the phone draws while one is up. Ahead of the editors below because they are nearer,
// terminal because a gesture that dismissed the surface underneath a menu would close two things at // and ahead of leaving the terminal because a gesture that dismissed the surface underneath a sheet
// once — and the one the user was looking at would not be either of them. // would close two things at once — and the one the user was looking at would not be either of them.
if (current.IsConnectSheetOpen) if (TryCloseATerminalSheet(current))
{ {
current.CloseConnectSheetCommand.Execute(null);
e.Handled = true; e.Handled = true;
return; return;
} }
@@ -647,6 +646,31 @@ internal sealed partial class PhoneShell : UserControl
} }
} }
/// <summary>
/// Lowers the connect menu or the forwards sheet, whichever is up over the terminal.
/// </summary>
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
/// <remarks>
/// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
/// ever open; the order below decides nothing.
/// </remarks>
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
{
if (current.IsConnectSheetOpen)
{
current.CloseConnectSheetCommand.Execute(null);
return true;
}
if (current.IsForwardsSheetOpen)
{
current.CloseForwardsSheetCommand.Execute(null);
return true;
}
return false;
}
/// <summary> /// <summary>
/// Lowers whatever the hosts screen has raised over its list, topmost first. /// Lowers whatever the hosts screen has raised over its list, topmost first.
/// </summary> /// </summary>
@@ -110,6 +110,34 @@
<Setter Property="Foreground" Value="{StaticResource Danger}" /> <Setter Property="Foreground" Value="{StaticResource Danger}" />
</Style> </Style>
<!--
── the forwards ──────────────────────────────────────────────────────────────────────────────────────
The desktop sidebar's state word, in the same three colours: green only while listening, the danger
text colour for a forward that could not start, faint for OFF and STARTING. The bar's ⇄ follows the
same rule for the failed case and is otherwise the colour of the icons beside it. Mono and 9.5, which
is this head's label lettering rather than the desktop's Montserrat, and its own family for the reason
the step captions above give.
-->
<Style Selector="TextBlock.fwdstate">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="FontSize" Value="9.5" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
<Style Selector="TextBlock.fwdstate.live">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.fwdstate.failed">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
<Style Selector="TextBlock.fwdglyph">
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
</Style>
<Style Selector="TextBlock.fwdglyph.failed">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
</UserControl.Styles> </UserControl.Styles>
<Panel> <Panel>
@@ -139,7 +167,7 @@
job — see the remark on the header in PhoneShell.axaml. --> job — see the remark on the header in PhoneShell.axaml. -->
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}" <Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto,Auto">
<!-- <!--
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
@@ -244,10 +272,37 @@
HOSTS, which is the only action on its screen; this one is not. HOSTS, which is the only action on its screen; this one is not.
Click as well as Command, and the handler does one thing the command cannot: put the software Click as well as Command, and the handler does one thing the command cannot: put the software
keyboard away. See OnConnectMenuRequested. keyboard away. See OnSheetRequested.
--> -->
<Button Grid.Column="2" Classes="icon" Content="+" Height="34" Margin="0,0,4,0" <!--
Click="OnConnectMenuRequested" The selected tab's forwards, which is what lets this head start them at all: a phone opening
listeners it never shows you is worse than one opening none. Drawn only for a tab that has some, so
a host without forwards pays nothing for the feature in a 35-pixel bar.
The count beside the arrows is the listening ones, in the green that means "running" everywhere
else; the arrows themselves turn the danger colour when one could not start. The desktop can leave a
refusal in its sidebar for the eye to find, because the sidebar is always drawn. The sheet this opens
is not, so the button has to say it first.
Outside the ScrollViewer with +, for the reason both ends are: a tenth tab must not push it away.
Width is not pinned to Button.icon's 44, because "⇄ 12" is wider than that; 44 is its minimum.
-->
<Button Grid.Column="2" Classes="icon" Height="34" Width="NaN" MinWidth="44" Padding="8,0"
Click="OnSheetRequested"
Command="{Binding OpenForwardsSheetCommand}"
IsVisible="{Binding SelectedTab.HasForwards, FallbackValue=False}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<TextBlock Classes="fwdglyph" Classes.failed="{Binding SelectedTab.HasFailedForward, FallbackValue=False}"
Text="⇄" FontSize="16" VerticalAlignment="Center" />
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource Live}"
VerticalAlignment="Center"
Text="{Binding SelectedTab.ListeningForwardCount, FallbackValue=''}"
IsVisible="{Binding SelectedTab.HasListeningForward, FallbackValue=False}" />
</StackPanel>
</Button>
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
Click="OnSheetRequested"
Command="{Binding OpenConnectSheetCommand}" /> Command="{Binding OpenConnectSheetCommand}" />
</Grid> </Grid>
</Border> </Border>
@@ -742,6 +797,75 @@
</Border> </Border>
</Panel> </Panel>
<!-- ============ the forwards sheet ============ -->
<!--
The desktop sidebar's FORWARDS section, as a sheet: the selected tab's forwards as they run over its
session, bound to the same rows. Each row is its own switch, as there — tap a listening one to stop it,
an off or failed one to start it, a failed one being a retry because the port may have been freed.
The route is written under the notation rather than left to a tooltip, which is where the desktop
keeps it: a phone has no hover, and which end each port is on is exactly what people get backwards
about -L and -R. The reason a row failed sits under that again, as the desktop draws it.
The same shape as the connect sheet above, raised from the bar the same way, collapsing the renderer
the same way — see MainWindowViewModel.IsForwardsSheetOpen. It scrolls, capped at most of the display,
because a host may carry more forwards than fit, and a sheet that grew past the top of the screen would
put its own title out of reach.
Nothing here edits a forward, and there is no add row: this head's host editor has no PORT FORWARDS
section yet, and the desktop's Add forward row leads to one. The footer says where they are edited.
-->
<Panel IsVisible="{Binding IsForwardsSheetOpen}">
<Button Classes="scrim" Command="{Binding CloseForwardsSheetCommand}" />
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
CornerRadius="22,22,0,0" Padding="20,18,20,16">
<StackPanel Spacing="0">
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
HorizontalAlignment="Center" Margin="0,0,0,16" />
<TextBlock Classes="title" Text="FORWARDS" FontSize="13" />
<TextBlock Classes="detail" FontSize="10.5" Margin="0,3,0,0"
Text="{Binding SelectedTab.Label, FallbackValue=''}" />
<ScrollViewer Margin="0,10,0,0" MaxHeight="420" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding SelectedTab.Forwards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PortForwardViewModel">
<Button Classes="row" Padding="12,8" Command="{Binding ToggleCommand}">
<Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
Text="{Binding Notation}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Text="{Binding Route}" />
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
Foreground="{StaticResource TextDim}" Text="{Binding Detail}"
IsVisible="{Binding Detail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="fwdstate" Margin="12,0,0,0"
Text="{Binding StateText}"
Classes.live="{Binding IsListening}" Classes.failed="{Binding IsFailed}" />
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
<TextBlock Classes="body" Margin="0,10,0,0"
Text="Tap a forward to switch it off or on. Local and SOCKS forwards listen on this phone's localhost, where any app on it can connect, and every forward stops with this tab's connection. Forwards are saved on the host and edited in the desktop app; a change starts with the next connection." />
<Button Classes="secondary" Content="DONE" Margin="0,12,0,0"
Command="{Binding CloseForwardsSheetCommand}" />
</StackPanel>
</Border>
</Panel>
</Panel> </Panel>
</UserControl> </UserControl>
@@ -210,10 +210,14 @@ internal sealed partial class TerminalScreen : UserControl
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire); private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
/// <summary> /// <summary>
/// Puts the keyboard away on the way to the connect menu. /// Puts the keyboard away on the way to the connect menu or the forwards sheet.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// One handler for both buttons in the terminal's bar that raise a sheet, since what follows is true of
/// either: each is a panel at the bottom of the screen, which is where the keyboard is.
/// </para>
/// <para>
/// A handler beside the button's command rather than something the command does, because the command is /// A handler beside the button's command rather than something the command does, because the command is
/// on the shared view model and this is a platform call: the desktop head has no software keyboard to /// on the shared view model and this is a platform call: the desktop head has no software keyboard to
/// put away, and a shell view model that knew about Android's input-method manager would be the first /// put away, and a shell view model that knew about Android's input-method manager would be the first
@@ -222,7 +226,7 @@ internal sealed partial class TerminalScreen : UserControl
/// <para> /// <para>
/// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing /// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing
/// into, so the sheet it raises would otherwise arrive underneath a keyboard covering half the display — /// into, so the sheet it raises would otherwise arrive underneath a keyboard covering half the display —
/// and the three rows on it are the bottom half. Worse, the keyboard's inset shortens everything the /// and the rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
/// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the /// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the
/// remaining strip rather than merely hidden behind it. /// remaining strip rather than merely hidden behind it.
/// </para> /// </para>
@@ -231,7 +235,7 @@ internal sealed partial class TerminalScreen : UserControl
/// what the other writes: this one talks to the platform and the command sets a flag, so they commute. /// what the other writes: this one talks to the platform and the command sets a flag, so they commute.
/// </para> /// </para>
/// </remarks> /// </remarks>
private void OnConnectMenuRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide(); private void OnSheetRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
/// <summary> /// <summary>
/// Takes the browser gestures back off a surface that is not a web page. /// Takes the browser gestures back off a surface that is not a web page.
+31 -1
View File
@@ -259,6 +259,22 @@ public interface ISyncApi
CancellationToken cancellationToken); CancellationToken cancellationToken);
} }
/// <summary>Asks the server for leave to relay one connection to one host.</summary>
/// <remarks>
/// The request names a host and nothing else. Where it is, and whether this caller may reach it, is the
/// server's decision, made from its own copy of the host's relay address — which is what keeps the relay
/// from being an open proxy that a client points wherever it likes. See ADR 0004.
/// </remarks>
public interface IRelayApi
{
/// <summary>Issues a single-use ticket, good for thirty seconds, for one relay socket to this host.</summary>
/// <exception cref="DodoSshApiException">
/// The server refused: <c>relay-unavailable</c>, <c>relay-target-rejected</c> or
/// <c>relay-limit-reached</c>, each with its own remedy.
/// </exception>
Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken);
}
/// <summary> /// <summary>
/// The typed client for one DodoSSH server. /// The typed client for one DodoSSH server.
/// </summary> /// </summary>
@@ -275,7 +291,7 @@ public interface ISyncApi
/// </para> /// </para>
/// </remarks> /// </remarks>
public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens) public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider tokens)
: IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi : IAccountApi, ISyncApi, ITeamApi, IDirectoryApi, IVaultGrantApi, IRelayApi
{ {
private const string MetaPath = "/api/v1/meta"; private const string MetaPath = "/api/v1/meta";
private const string ConfigurationPath = "/.well-known/dodossh-configuration"; private const string ConfigurationPath = "/.well-known/dodossh-configuration";
@@ -310,6 +326,20 @@ public sealed class DodoSshApiClient(HttpClient http, IAccessTokenProvider token
DodoSshJsonContext.Default.DodoSshConfiguration, DodoSshJsonContext.Default.DodoSshConfiguration,
cancellationToken); cancellationToken);
/// <inheritdoc />
/// <remarks>
/// Never cached, and never fetched ahead of need: a ticket is spent by the socket it opens and is
/// worthless thirty seconds after issue, so the only right moment to ask for one is the connect that
/// will use it.
/// </remarks>
public Task<RelayTicketResponse> IssueRelayTicketAsync(Guid hostId, CancellationToken cancellationToken) =>
SendAsync(
HttpMethod.Post,
RelayProtocol.TicketsPath,
JsonContent.Create(new RelayTicketRequest(hostId, PortForwardId: null), DodoSshJsonContext.Default.RelayTicketRequest),
DodoSshJsonContext.Default.RelayTicketResponse,
cancellationToken);
/// <summary> /// <summary>
/// Reads the caller's profile, unlock material and reachable vaults. /// Reads the caller's profile, unlock material and reachable vaults.
/// </summary> /// </summary>
+78 -32
View File
@@ -26,6 +26,20 @@ namespace DodoSSH.Client.App;
/// </remarks> /// </remarks>
internal sealed partial class DodoSshApp : Application internal sealed partial class DodoSshApp : Application
{ {
/// <summary>
/// Where a <c>dodossh://</c> link goes once the shell exists; null until then.
/// </summary>
/// <remarks>
/// Links can arrive before there is anything to hand them to — macOS delivers the one an application was
/// opened with as an event shortly after launch, which may be while the splash is still up — so until
/// <see cref="Compose"/> sets this, the latest is kept in <see cref="waitingLink"/>. Both are touched on
/// the UI thread only.
/// </remarks>
private static Action<SpelunkLink>? openLink;
/// <summary>A link that arrived before <see cref="openLink"/> was set. See there.</summary>
private static SpelunkLink? waitingLink;
/// <inheritdoc /> /// <inheritdoc />
public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void Initialize() => AvaloniaXamlLoader.Load(this);
@@ -34,12 +48,63 @@ internal sealed partial class DodoSshApp : Application
{ {
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
// Recorded, not handled: see CrashLog for why the process still goes.
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
ListenForOpenedLinks();
ShowSomethingWhileComposing(desktop); ShowSomethingWhileComposing(desktop);
} }
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
/// <summary>
/// Takes the links the platform hands a running application, which on macOS is every link.
/// </summary>
/// <remarks>
/// <para>
/// Windows and Linux start the executable with the link as an argument, and <c>Program.Main</c> reads it
/// there — or passes it to the instance already running. macOS does neither: Launch Services sends the
/// running application, or the one it has just started, an "open URL" event, which Avalonia raises as an
/// activation. The scheme is declared for it in build/macos's Info.plist template.
/// </para>
/// <para>
/// Checked exactly as a launch argument is, by <see cref="SpelunkLink.TryParse"/>; anything else is
/// ignored. Where the lifetime offers no activation at all this does nothing, which is the answer on the
/// desktops that deliver links as arguments instead.
/// </para>
/// </remarks>
private void ListenForOpenedLinks()
{
if (TryGetFeature(typeof(IActivatableLifetime)) is not IActivatableLifetime activatable)
{
return;
}
activatable.Activated += (_, e) =>
{
if (e is ProtocolActivatedEventArgs { Kind: ActivationKind.OpenUri, Uri: { } uri }
&& SpelunkLink.TryParse(uri.OriginalString, out var link))
{
Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link));
}
};
}
/// <summary>Hands a link to the shell, or keeps it until there is one. UI thread only.</summary>
private static void Receive(SpelunkLink link)
{
if (openLink is { } open)
{
open(link);
}
else
{
waitingLink = link;
}
}
/// <summary> /// <summary>
/// Puts a window on screen first, and composes the application behind it. /// Puts a window on screen first, and composes the application behind it.
/// </summary> /// </summary>
@@ -206,9 +271,20 @@ internal sealed partial class DodoSshApp : Application
// The other half of Program's claim on this profile: a second launch was refused, and this is what // The other half of Program's claim on this profile: a second launch was refused, and this is what
// answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and // answers it. Posted onto the UI thread because a pipe accept completes on a thread pool thread and
// a window may only be touched from the thread that owns it — see SingleInstance.Listen. // a window may only be touched from the thread that owns it — see SingleInstance.Listen.
// A second launch's dodossh:// link comes the same way as this launch's own and macOS's: through
// Receive on the UI thread, then MainWindowViewModel.OpenLink, which holds it until a vault is open.
var others = SingleInstance.Listen( var others = SingleInstance.Listen(
paths, paths,
() => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow))); () => Avalonia.Threading.Dispatcher.UIThread.Post(() => BringToFront(desktop.MainWindow)),
link => Avalonia.Threading.Dispatcher.UIThread.Post(() => Receive(link)));
openLink = viewModel.OpenLink;
if ((waitingLink ?? Program.LaunchLink) is { } first)
{
waitingLink = null;
viewModel.OpenLink(first);
}
// Started rather than awaited: the framework's initialisation must not block on a schema // Started rather than awaited: the framework's initialisation must not block on a schema
// migration. The view model shows its own progress and handles its own failures, which is why // migration. The view model shows its own progress and handles its own failures, which is why
@@ -280,39 +356,9 @@ internal sealed partial class DodoSshApp : Application
copyToClipboard: ClipboardWriter(desktop), copyToClipboard: ClipboardWriter(desktop),
readClipboard: ClipboardReader(desktop), readClipboard: ClipboardReader(desktop),
updates: updates, updates: updates,
openInEditor: OpenInDefaultEditorAsync, openInEditor: LocalEditor.OpenAsync,
watchFile: WatchFile); watchFile: WatchFile);
/// <summary>
/// Hands a local file to whatever this machine opens that kind of file with.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
/// Services binding depending on which desktop this is, and watching a file is a platform object with
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
private static Task OpenInDefaultEditorAsync(string path)
{
using var opened = System.Diagnostics.Process.Start(
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
return Task.CompletedTask;
}
/// <summary> /// <summary>
/// Watches one file for saves, until the handle is disposed. /// Watches one file for saves, until the handle is disposed.
/// </summary> /// </summary>
@@ -0,0 +1,85 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Writes down why the application died, in the profile, before it does.
/// </summary>
/// <remarks>
/// <para>
/// An exception nothing caught used to close the window and leave nothing behind — no file, no event, no
/// sentence — so the only report anybody could make was "it closed". This does not keep the process alive:
/// an exception that reached the top is one no code was written to recover from, and carrying on in whatever
/// state it left would be a guess. What it adds is the record.
/// </para>
/// <para>
/// Bounded, and appended to rather than rewritten, so a run of crashes keeps its history without the file
/// growing for ever. Every write is best-effort: a profile that cannot be written to is a reason to lose the
/// log, never a reason to fail harder.
/// </para>
/// </remarks>
internal static class CrashLog
{
/// <summary>Past this, the next entry starts a fresh file.</summary>
private const long MaxBytes = 1024 * 1024;
private static readonly Lock Gate = new();
private static string? file;
/// <summary>Starts recording, for the process's two last-chance hooks.</summary>
/// <remarks>
/// The UI thread's own hook is added by the application once Avalonia is running — see
/// <see cref="RecordFromTheUiThread"/> — because the dispatcher must not be touched before the platform
/// has set it up.
/// </remarks>
internal static void Install(ClientPaths paths)
{
ArgumentNullException.ThrowIfNull(paths);
file = paths.CrashLogFile;
AppDomain.CurrentDomain.UnhandledException += (_, e) => Write("Unhandled", e.ExceptionObject as Exception);
// A fire-and-forget task that faulted. It does not end the process, which is exactly why it needs
// writing down: the work it was doing simply never happened, and nothing else would ever say so.
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Write("Unobserved task", e.Exception);
e.SetObserved();
};
}
/// <summary>The UI thread's hook: records the exception and lets it take the process as before.</summary>
internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
/// <summary>Appends one entry; never throws.</summary>
internal static void Write(string where, Exception? exception)
{
if (file is not { } path)
{
return;
}
var entry = $"{TimeProvider.System.GetUtcNow():O} {where}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
lock (Gate)
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
if (File.Exists(path) && new FileInfo(path).Length > MaxBytes)
{
File.Delete(path);
}
File.AppendAllText(path, entry);
}
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
{
// Best-effort, by design: see the type's remarks.
}
}
}
}
@@ -0,0 +1,48 @@
using System.Security.Cryptography;
using System.Text;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// What a platform key store calls the hardware key behind one profile's device unlock.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>One key per profile, because the key outlives the file and is shared by name.</b> The CNG key and
/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
/// profile directory. While every profile used the same name, the release build and the nightly — which
/// <see cref="ClientPaths.ForChannel"/> keeps apart precisely so neither can damage the other — shared one
/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
/// </para>
/// <para>
/// <b>The default profile keeps the name it has always had</b>, so an existing install's sealed key still
/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
/// than the directory's own name, because a portable copy can live in any folder and two such folders can
/// end in the same name.
/// </para>
/// </remarks>
internal static class DeviceKeyNames
{
/// <summary>The name of the key behind <paramref name="paths"/>' device unlock.</summary>
/// <param name="paths">The profile.</param>
/// <param name="legacy">The name every profile used before names were per profile.</param>
internal static string For(ClientPaths paths, string legacy)
{
ArgumentNullException.ThrowIfNull(paths);
var directory = Normalise(paths.DataDirectory);
if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
{
return legacy;
}
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
}
private static string Normalise(string directory) =>
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
}
@@ -0,0 +1,126 @@
using System.Diagnostics;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Opens a local copy of a remote file for editing, without running it.
/// </summary>
/// <remarks>
/// <para>
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
/// shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch Services binding
/// depending on which desktop this is. The shell decides <em>when</em>; see
/// <c>TransfersViewModel.EditRemoteAsync</c>.
/// </para>
/// <para>
/// ◆ <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
/// which is the default text editor whatever the type. Everything else still goes to its association, which
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
/// </para>
/// <para>
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
/// by every mainstream desktop's file associations in an editor, not run.
/// </para>
/// <para>
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
/// </para>
/// </remarks>
internal static class LocalEditor
{
/// <summary>
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
/// </summary>
/// <remarks>
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
/// items, and the script hosts that installers do not register there.
/// </remarks>
private static readonly string[] WindowsRunsThese =
[
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
];
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
private static readonly string[] MacRunsThese =
[
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
".tool", ".webloc", ".workflow",
];
/// <summary>Opens <paramref name="path"/> for editing.</summary>
/// <exception cref="InvalidOperationException">
/// This desktop would run the file and has no editor to offer in its place.
/// </exception>
internal static Task OpenAsync(string path)
{
ArgumentNullException.ThrowIfNull(path);
using var opened = Process.Start(StartInfoFor(path));
return Task.CompletedTask;
}
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
internal static ProcessStartInfo StartInfoFor(string path)
{
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
{
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
{
return edit;
}
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
}
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
{
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
}
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
{
throw new InvalidOperationException(
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
+ "Download it and open it in an editor instead.");
}
return new ProcessStartInfo(path) { UseShellExecute = true };
}
/// <summary>Whether Windows' default action for this file is to run it.</summary>
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
{
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
}
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
/// <remarks>
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
/// <c>run.bat</c> by the time anything acts on it.
/// </remarks>
private static bool HasExtension(string path, IEnumerable<string> extensions)
{
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
return extension.Length > 0
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
}
}
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths; private readonly ClientPaths paths;
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
/// <remarks>
/// <see cref="KeyTag"/> for the default profile and a tag of its own for every other, so that signing
/// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
/// registered before this is still sealed to the shared tag and is read from there until it registers
/// again; it is never deleted from a profile that does not own it.
/// </remarks>
private readonly string keyTag;
/// <summary>Creates the store.</summary> /// <summary>Creates the store.</summary>
public MacDeviceKeyStore(ClientPaths paths) public MacDeviceKeyStore(ClientPaths paths)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
this.paths = paths; this.paths = paths;
keyTag = DeviceKeyNames.For(paths, KeyTag);
} }
/// <summary> /// <summary>
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> devicePrivateKey, ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var sealedKey = Seal(devicePrivateKey.Span) var sealedKey = Seal(devicePrivateKey.Span, keyTag)
?? throw new InvalidOperationException( ?? throw new InvalidOperationException(
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one."); "The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken) var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return Unseal(sealedKey); // The shared tag only while this profile has no key of its own, so one unlock is one prompt.
return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here, // Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
// and the file deleted above is the half that decides whether unlock will try at all. A key // and the file deleted above is the half that decides whether unlock will try at all. A key
// left in the enclave with no ciphertext to open is inert. // left in the enclave with no ciphertext to open is inert.
_ = DeleteKey(symbols, KeyTag); _ = DeleteKey(symbols, keyTag);
} }
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of /// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
/// them is the same — do not offer a device unlock. /// them is the same — do not offer a device unlock.
/// </remarks> /// </remarks>
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey) /// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
private static bool HasKey(string tag)
{
try
{
using var scope = new CoreFoundationScope();
var symbols = MacSymbols.Resolve();
return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
}
catch (Exception exception) when (exception is DllNotFoundException
or EntryPointNotFoundException
or BadImageFormatException)
{
return false;
}
}
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey, string tag)
{ {
try try
{ {
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
// Created on first use rather than at registration, so that a device key re-registered after // Created on first use rather than at registration, so that a device key re-registered after
// a ForgetAsync gets a key again without anything having to notice that it had gone. // a ForgetAsync gets a key again without anything having to notice that it had gone.
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null); var privateKey = FindKey(scope, symbols, tag, prompt: null);
if (privateKey == IntPtr.Zero) if (privateKey == IntPtr.Zero)
{ {
privateKey = CreateEnclaveKey(scope, symbols, KeyTag); privateKey = CreateEnclaveKey(scope, symbols, tag);
} }
if (privateKey == IntPtr.Zero) if (privateKey == IntPtr.Zero)
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
/// on a thread that is also expected to draw the window behind it. /// on a thread that is also expected to draw the window behind it.
/// </para> /// </para>
/// </remarks> /// </remarks>
private static byte[]? Unseal(byte[] sealedKey) private static byte[]? Unseal(byte[] sealedKey, string tag)
{ {
try try
{ {
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
return null; return null;
} }
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt); var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
if (privateKey == IntPtr.Zero) if (privateKey == IntPtr.Zero)
{ {
@@ -0,0 +1,62 @@
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// The device key store this machine supports, chosen once and off the UI thread.
/// </summary>
/// <remarks>
/// <para>
/// Choosing a store means probing for it, and on Windows the probe is creating an RSA-2048 key inside the TPM
/// and deleting it again — see <see cref="WindowsDeviceKeyStore.IsSupported"/> — which is a slow device doing
/// real work. It used to run synchronously on the UI thread three times over: once to choose the store at
/// startup, again when the unlock card asked whether to offer the gesture, and again after every unlock when
/// the security page asked whether to offer registering. A machine does not gain or lose a TPM while this
/// process runs, so the answer is worked out once, on the thread pool, and every question after it is a
/// lookup.
/// </para>
/// <para>
/// Available means the choice landed on a real store, which is what the probe that chose it established.
/// Everything else waits for the choice and hands over to it — in practice it has long since finished,
/// since nothing reaches for the device key until somebody is at the unlock card.
/// </para>
/// </remarks>
internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
{
private readonly Task<IDeviceKeyStore> chosen;
/// <summary>Starts choosing at once, on the thread pool.</summary>
internal ProbedDeviceKeyStore(Func<IDeviceKeyStore> choose)
{
ArgumentNullException.ThrowIfNull(choose);
chosen = Task.Run(choose);
}
/// <summary>The store the probe settled on, for a test that needs to know which.</summary>
internal Task<IDeviceKeyStore> Chosen => chosen;
/// <inheritdoc />
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
/// <inheritdoc />
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async ValueTask ForgetAsync(CancellationToken cancellationToken)
{
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
}
}
+129 -10
View File
@@ -34,6 +34,12 @@ namespace DodoSSH.Client.App.Platform;
/// process asks the first to come forward and then leaves, which is what every desktop application does and /// process asks the first to come forward and then leaves, which is what every desktop application does and
/// what makes the exclusion invisible rather than mysterious. /// what makes the exclusion invisible rather than mysterious.
/// </para> /// </para>
/// <para>
/// <b>A launch that carries a link passes the link on.</b> Clicking "Open in DodoSSH" in Spelunk starts
/// this executable with a <c>dodossh://</c> link, and while DodoSSH is already running that start is a
/// second launch like any other — so the doorbell has a second message, which carries the link to the
/// instance that can act on it. See <see cref="OpenLinkPrefix"/>.
/// </para>
/// </remarks> /// </remarks>
internal static class SingleInstance internal static class SingleInstance
{ {
@@ -46,6 +52,30 @@ internal static class SingleInstance
/// </remarks> /// </remarks>
private const string ComeForward = "dodossh.come-forward"; private const string ComeForward = "dodossh.come-forward";
/// <summary>The other thing a second launch may say: come forward, and open this link.</summary>
/// <remarks>
/// <para>
/// Followed by the link, on the same one line. Checked as strictly as <see cref="ComeForward"/> is, and
/// for the same reason: anything on this machine may write to the pipe, so the link is parsed again here
/// by <see cref="SpelunkLink.TryParse"/>, and a message that is not exactly this prefix and a link that
/// passes is ignored whole — the window does not even come forward for it.
/// </para>
/// <para>
/// A second message rather than a second line after <see cref="ComeForward"/>, so that an older running
/// build, which compares the whole line against that constant, ignores a link it cannot act on rather
/// than half-obeying it.
/// </para>
/// </remarks>
private const string OpenLinkPrefix = "dodossh.open-link ";
/// <summary>The longest message the doorbell reads before it stops listening to that caller.</summary>
/// <remarks>
/// The longest link a second launch could send, and its prefix. A caller that goes on past it is not a
/// DodoSSH, and reading a line of whatever length it likes would let it decide how much memory this
/// process spends on it.
/// </remarks>
private const int MaxMessageLength = 64 + SpelunkLink.MaxLength;
/// <summary>How long a second launch waits for the first to answer its pipe.</summary> /// <summary>How long a second launch waits for the first to answer its pipe.</summary>
/// <remarks> /// <remarks>
/// Short, because the pipe is local and the process on the other end is either listening or not. It is /// Short, because the pipe is local and the process on the other end is either listening or not. It is
@@ -86,7 +116,11 @@ internal static class SingleInstance
/// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named /// How long to keep trying a lock nobody answers for, or null for <see cref="DefaultPatience"/>. Named
/// by a test so the give-up path does not cost the suite three seconds; the application never passes it. /// by a test so the give-up path does not cost the suite three seconds; the application never passes it.
/// </param> /// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null) /// <param name="link">
/// The link this launch was started with, if any — passed to the running instance instead of a bare
/// request to come forward.
/// </param>
internal static IDisposable? Claim(ClientPaths paths, TimeSpan? patience = null, SpelunkLink? link = null)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
@@ -116,7 +150,7 @@ internal static class SingleInstance
// Asked before waiting, so the ordinary case — an instance that is up and running — costs one // Asked before waiting, so the ordinary case — an instance that is up and running — costs one
// round trip on a local pipe rather than the whole window below. // round trip on a local pipe rather than the whole window below.
if (AskTheRunningOneToComeForward(paths)) if (AskTheRunningOneToComeForward(paths, link))
{ {
return null; return null;
} }
@@ -142,25 +176,32 @@ internal static class SingleInstance
/// </remarks> /// </remarks>
/// <param name="paths">The profile this instance claimed.</param> /// <param name="paths">The profile this instance claimed.</param>
/// <param name="comeForward">What to do when another launch asks for the window.</param> /// <param name="comeForward">What to do when another launch asks for the window.</param>
internal static IAsyncDisposable Listen(ClientPaths paths, Action comeForward) /// <param name="openLink">
/// What to do with a link another launch passed on, after <paramref name="comeForward"/>; null to ignore
/// links, which then still bring the window forward. Invoked on the same thread pool thread.
/// </param>
internal static IAsyncDisposable Listen(
ClientPaths paths,
Action comeForward,
Action<SpelunkLink>? openLink = null)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(comeForward); ArgumentNullException.ThrowIfNull(comeForward);
var listener = new Doorbell(PipeNameFor(paths), comeForward); var listener = new Doorbell(PipeNameFor(paths), comeForward, openLink);
listener.Start(); listener.Start();
return listener; return listener;
} }
/// <summary>Tells the instance that holds this profile to raise its window.</summary> /// <summary>Tells the instance that holds this profile to raise its window, and to open a link if given one.</summary>
/// <returns>Whether anything was listening.</returns> /// <returns>Whether anything was listening.</returns>
/// <remarks> /// <remarks>
/// Internal rather than private because it is the half a test can drive: the exclusion is a file handle /// Internal rather than private because it is the half a test can drive: the exclusion is a file handle
/// and proves itself, and this is the part with a protocol in it. /// and proves itself, and this is the part with a protocol in it.
/// </remarks> /// </remarks>
internal static bool AskTheRunningOneToComeForward(ClientPaths paths) internal static bool AskTheRunningOneToComeForward(ClientPaths paths, SpelunkLink? link = null)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
@@ -175,7 +216,9 @@ internal static class SingleInstance
using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true }; using var writer = new StreamWriter(pipe, Encoding.UTF8) { AutoFlush = true };
writer.WriteLine(ComeForward); // The link as this build writes it, rather than as it arrived: the receiver parses what a DodoSSH
// sent, and a link that parsed here has exactly one spelling. See SpelunkLink.ToString.
writer.WriteLine(Message(link));
return true; return true;
} }
@@ -188,6 +231,42 @@ internal static class SingleInstance
} }
} }
/// <summary>What a launch says down the pipe: come forward, with or without a link to open.</summary>
internal static string Message(SpelunkLink? link) =>
link is null ? ComeForward : OpenLinkPrefix + link;
/// <summary>
/// Reads what a caller said: whether it is a ring at all, and the link that came with it.
/// </summary>
/// <returns>
/// True for exactly <see cref="ComeForward"/>, with no link, and for <see cref="OpenLinkPrefix"/> followed
/// by a link <see cref="SpelunkLink.TryParse"/> accepts, with that link. False for everything else,
/// including a link that is almost right — the caller then does nothing at all.
/// </returns>
internal static bool TryRead(string? said, out SpelunkLink? link)
{
link = null;
if (said is null || said.Length > MaxMessageLength)
{
return false;
}
if (string.Equals(said, ComeForward, StringComparison.Ordinal))
{
return true;
}
if (said.StartsWith(OpenLinkPrefix, StringComparison.Ordinal)
&& SpelunkLink.TryParse(said[OpenLinkPrefix.Length..], out var parsed))
{
link = parsed;
return true;
}
return false;
}
/// <summary>Takes the lock, or answers null if somebody else has it.</summary> /// <summary>Takes the lock, or answers null if somebody else has it.</summary>
/// <remarks> /// <remarks>
/// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks /// The file is left in place rather than deleted on close. <c>FileOptions.DeleteOnClose</c> looks
@@ -278,14 +357,16 @@ internal static class SingleInstance
{ {
private readonly string name; private readonly string name;
private readonly Action comeForward; private readonly Action comeForward;
private readonly Action<SpelunkLink>? openLink;
private readonly CancellationTokenSource lifetime = new(); private readonly CancellationTokenSource lifetime = new();
private Task? loop; private Task? loop;
internal Doorbell(string name, Action comeForward) internal Doorbell(string name, Action comeForward, Action<SpelunkLink>? openLink)
{ {
this.name = name; this.name = name;
this.comeForward = comeForward; this.comeForward = comeForward;
this.openLink = openLink;
} }
internal void Start() => loop = RunAsync(lifetime.Token); internal void Start() => loop = RunAsync(lifetime.Token);
@@ -324,11 +405,16 @@ internal static class SingleInstance
// leaveOpen, because this server outlives the conversation. See the remark above. // leaveOpen, because this server outlives the conversation. See the remark above.
using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true)) using (var reader = new StreamReader(server, Encoding.UTF8, false, 1024, leaveOpen: true))
{ {
var said = await reader.ReadLineAsync(cancellationToken).ConfigureAwait(false); var said = await ReadBoundedLineAsync(reader, cancellationToken).ConfigureAwait(false);
if (string.Equals(said, ComeForward, StringComparison.Ordinal)) if (TryRead(said, out var link))
{ {
comeForward(); comeForward();
if (link is not null)
{
openLink?.Invoke(link);
}
} }
} }
@@ -348,6 +434,39 @@ internal static class SingleInstance
} }
} }
/// <summary>
/// Reads one line, or null once it runs past <see cref="MaxMessageLength"/> or the caller hangs up.
/// </summary>
/// <remarks>
/// <see cref="StreamReader.ReadLineAsync(CancellationToken)"/> reads a line of whatever length the
/// caller sends, and the caller is anybody on this machine. A character at a time is cheap here — the
/// reader buffers underneath, and a real message is one short line.
/// </remarks>
private static async Task<string?> ReadBoundedLineAsync(
StreamReader reader,
CancellationToken cancellationToken)
{
var line = new StringBuilder();
var next = new char[1];
while (await reader.ReadAsync(next.AsMemory(), cancellationToken).ConfigureAwait(false) == 1)
{
if (next[0] == '\n')
{
return line.ToString().TrimEnd('\r');
}
if (line.Length == MaxMessageLength)
{
return null;
}
line.Append(next[0]);
}
return line.Length == 0 ? null : line.ToString().TrimEnd('\r');
}
/// <summary>Ends one conversation without ending the doorbell.</summary> /// <summary>Ends one conversation without ending the doorbell.</summary>
/// <remarks> /// <remarks>
/// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state /// Guarded, because <c>Disconnect</c> throws on a server that is not connected — which is the state
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
/// </remarks> /// </remarks>
public static class DesktopDeviceKeyStores public static class DesktopDeviceKeyStores
{ {
/// <summary>The best store this machine supports.</summary> /// <summary>The best store this machine supports, chosen on the thread pool.</summary>
/// <remarks>
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
/// thread at startup.
/// </remarks>
/// <param name="paths">Where a stored key lives.</param> /// <param name="paths">Where a stored key lives.</param>
/// <param name="askForPin"> /// <param name="askForPin">
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform /// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
@@ -57,6 +62,13 @@ public static class DesktopDeviceKeyStores
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(askForPin); ArgumentNullException.ThrowIfNull(askForPin);
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
}
private static IDeviceKeyStore Choose(
ClientPaths paths,
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
{
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported()) if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{ {
return new WindowsDeviceKeyStore(paths); return new WindowsDeviceKeyStore(paths);
@@ -123,6 +135,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// rather than failing to open blobs written by a previous build. A device that cannot be opened /// rather than failing to open blobs written by a previous build. A device that cannot be opened
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice /// falls back to the passphrase, which is survivable — but silently, and a user would only notice
/// their gesture had stopped working. /// their gesture had stopped working.
/// <para>
/// This is the default profile's key, and the name every profile shared before keys were per profile;
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
/// </para>
/// </remarks> /// </remarks>
private const string KeyName = "DodoSSH.DeviceKey.v1"; private const string KeyName = "DodoSSH.DeviceKey.v1";
@@ -133,11 +149,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
private readonly ClientPaths paths; private readonly ClientPaths paths;
/// <summary>This profile's own key.</summary>
/// <remarks>
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
/// profile that does not own it, which is the half of this that matters.
/// </remarks>
private readonly string keyName;
/// <summary>Creates the store.</summary> /// <summary>Creates the store.</summary>
public WindowsDeviceKeyStore(ClientPaths paths) public WindowsDeviceKeyStore(ClientPaths paths)
{ {
ArgumentNullException.ThrowIfNull(paths); ArgumentNullException.ThrowIfNull(paths);
this.paths = paths; this.paths = paths;
keyName = DeviceKeyNames.For(paths, KeyName);
} }
/// <summary> /// <summary>
@@ -182,7 +209,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
ReadOnlyMemory<byte> devicePrivateKey, ReadOnlyMemory<byte> devicePrivateKey,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
using var key = OpenOrCreate(); using var key = OpenOrCreate(keyName);
using var rsa = new RSACng(key); using var rsa = new RSACng(key);
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256); var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
@@ -204,7 +231,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken) var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return Unseal(sealedKey); // The shared key only while this profile has none of its own: a profile registered before keys were
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
? KeyName
: keyName;
return Unseal(sealedKey, sealedTo);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -215,9 +248,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
File.Delete(paths.DeviceKeyFile); File.Delete(paths.DeviceKeyFile);
} }
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider))) // This profile's key and never the shared one, unless this is the profile that owns it. Another
// profile's device unlock may still be sealed to it.
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
{ {
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider)); using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
key.Delete(); key.Delete();
} }
@@ -231,16 +266,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// user and all have the same remedy, so none of them are worth telling apart here — see /// user and all have the same remedy, so none of them are worth telling apart here — see
/// <c>UnlockStatus.DeviceKeyUnavailable</c>. /// <c>UnlockStatus.DeviceKeyUnavailable</c>.
/// </remarks> /// </remarks>
private static byte[]? Unseal(byte[] sealedKey) private static byte[]? Unseal(byte[] sealedKey, string name)
{ {
try try
{ {
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider))) if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
{ {
return null; return null;
} }
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider)); using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
using var rsa = new RSACng(key); using var rsa = new RSACng(key);
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256); return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
@@ -256,18 +291,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and /// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
/// silently is the one behaviour this whole file exists to prevent. /// silently is the one behaviour this whole file exists to prevent.
/// </remarks> /// </remarks>
private static CngKey OpenOrCreate() private static CngKey OpenOrCreate(string name)
{ {
var provider = new CngProvider(PlatformProvider); var provider = new CngProvider(PlatformProvider);
if (CngKey.Exists(KeyName, provider)) if (CngKey.Exists(name, provider))
{ {
return CngKey.Open(KeyName, provider); return CngKey.Open(name, provider);
} }
return CngKey.Create( return CngKey.Create(
CngAlgorithm.Rsa, CngAlgorithm.Rsa,
KeyName, name,
new CngKeyCreationParameters new CngKeyCreationParameters
{ {
Provider = provider, Provider = provider,
@@ -0,0 +1,82 @@
using System.Runtime.Versioning;
using DodoSSH.Client.Session;
using Microsoft.Win32;
namespace DodoSSH.Client.App.Platform;
/// <summary>
/// Registers this executable as the handler for <c>dodossh://</c> links, for this user, on Windows.
/// </summary>
/// <remarks>
/// <para>
/// <b>Under HKCU\Software\Classes, because the install is per user.</b> Velopack installs into the user's
/// own profile with no elevation, and a handler registered machine-wide would need the elevation the
/// installer deliberately never asks for. Windows merges this key into <c>HKEY_CLASSES_ROOT</c> for the
/// user, which is all a browser consults.
/// </para>
/// <para>
/// <b>Written by Velopack's install and update hooks, removed by its uninstall hook</b> — see
/// <c>Program.Main</c>. An update rewrites it because the first update to a build that has this is how an
/// existing installation gets it at all, and because it costs nothing to say again.
/// </para>
/// <para>
/// <b>Removed only when it is still ours.</b> The release and the nightly are separate installations that
/// both answer <c>dodossh://</c>, and the last one installed holds the key. Uninstalling the other must not
/// take the handler away from the one that is still there.
/// </para>
/// </remarks>
[SupportedOSPlatform("windows")]
internal static class WindowsUrlScheme
{
private const string KeyPath = @"Software\Classes\" + SpelunkLink.Scheme;
private const string CommandPath = @"shell\open\command";
/// <summary>Points <c>dodossh://</c> at <paramref name="executable"/>.</summary>
internal static void Register(string executable)
{
using var scheme = Registry.CurrentUser.CreateSubKey(KeyPath);
scheme.SetValue(string.Empty, "URL:DodoSSH");
// Empty, and its presence is the whole of it: this value is what makes the key a URL scheme rather
// than a file type.
scheme.SetValue("URL Protocol", string.Empty);
using (var icon = scheme.CreateSubKey("DefaultIcon"))
{
icon.SetValue(string.Empty, $"\"{executable}\",0");
}
using var command = scheme.CreateSubKey(CommandPath);
command.SetValue(string.Empty, CommandFor(executable));
}
/// <summary>Takes the handler away, if it is still <paramref name="executable"/>'s.</summary>
internal static void Unregister(string executable)
{
string? registered;
using (var command = Registry.CurrentUser.OpenSubKey($@"{KeyPath}\{CommandPath}"))
{
registered = command?.GetValue(string.Empty) as string;
}
if (IsOurs(registered, executable))
{
Registry.CurrentUser.DeleteSubKeyTree(KeyPath, throwOnMissingSubKey: false);
}
}
/// <summary>The command Windows runs for a link: this executable, with the link as its one argument.</summary>
/// <remarks>
/// Both halves quoted. The path has spaces in it on any profile whose user name does, and the link is
/// handed over whole as <c>%1</c> — which Windows quotes into one argument only because it is quoted
/// here. Program.Main then reads it back and checks it before anything acts on it.
/// </remarks>
internal static string CommandFor(string executable) => $"\"{executable}\" \"%1\"";
/// <summary>Whether a registered command is the one <see cref="Register"/> writes for this executable.</summary>
internal static bool IsOurs(string? registered, string executable) =>
string.Equals(registered, CommandFor(executable), StringComparison.OrdinalIgnoreCase);
}
+104 -6
View File
@@ -8,6 +8,16 @@ namespace DodoSSH.Client.App;
internal static class Program internal static class Program
{ {
/// <summary>
/// The <c>dodossh://</c> link this process was started with, once it has claimed the profile; null for an
/// ordinary launch.
/// </summary>
/// <remarks>
/// A static handed across to <c>DodoSshApp</c> rather than a constructor argument, because Avalonia
/// constructs the application itself — and the designer does too, through <see cref="BuildAvaloniaApp"/>,
/// with no arguments to have.
/// </remarks>
internal static SpelunkLink? LaunchLink { get; private set; }
/// <summary> /// <summary>
/// Entry point. /// Entry point.
/// </summary> /// </summary>
@@ -29,11 +39,12 @@ internal static class Program
// and a silent installer run that reached either would be a background process holding the cache // and a silent installer run that reached either would be a background process holding the cache
// file open during the very file operations the installer is performing. // file open during the very file operations the installer is performing.
// //
// There are deliberately no OnFirstRun or OnAfterUpdate hooks. A hook process has no passphrase, // There are deliberately no OnFirstRun or OnAfterUpdate hooks that touch the profile. A hook process
// so the cache is bytes it cannot read, and the one thing that would want doing after an update — // has no passphrase, so the cache is bytes it cannot read, and the one thing that would want doing
// a schema migration — already runs on every ordinary launch from MainWindowViewModel.StartAsync, // after an update — a schema migration — already runs on every ordinary launch from
// before unlock and touching no encrypted content. // MainWindowViewModel.StartAsync, before unlock and touching no encrypted content. The hooks it does
VelopackApp.Build().Run(); // have touch only the registry; see RunVelopack.
RunVelopack();
KeepTheWebViewProfileOutOfTheInstallDirectory(); KeepTheWebViewProfileOutOfTheInstallDirectory();
@@ -51,16 +62,103 @@ internal static class Program
*/ */
var paths = ClientPaths.ForChannel(DesktopChannel.Name); var paths = ClientPaths.ForChannel(DesktopChannel.Name);
using var claim = SingleInstance.Claim(paths); // Read before the claim, because a launch that carries a link and finds DodoSSH already running
// passes the link on rather than merely asking for the window.
var link = LinkFrom(args);
using var claim = SingleInstance.Claim(paths, link: link);
if (claim is null) if (claim is null)
{ {
return; return;
} }
LaunchLink = link;
// After the claim, so a second launch that only hands its link over writes nothing.
CrashLog.Install(paths);
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args); BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
} }
/// <summary>Lets Velopack do its install bookkeeping, with this application's three hooks.</summary>
/// <remarks>
/// The hooks touch only the registry: they make this executable the handler for <c>dodossh://</c> links,
/// which is how Spelunk's "Open in DodoSSH" reaches it, and take that away again on uninstall. Velopack
/// runs them on Windows only, and offers them only there; the other desktops declare the scheme in their
/// packages instead — build/macos's Info.plist template and build/linux's desktop entry. See
/// <see cref="WindowsUrlScheme"/>.
/// </remarks>
private static void RunVelopack()
{
var velopack = VelopackApp.Build();
if (OperatingSystem.IsWindows())
{
velopack = velopack
.OnAfterInstallFastCallback(_ => RegisterUrlScheme())
.OnAfterUpdateFastCallback(_ => RegisterUrlScheme())
.OnBeforeUninstallFastCallback(_ => UnregisterUrlScheme());
}
velopack.Run();
}
/// <summary>
/// The <c>dodossh://</c> link among this launch's arguments, if there is one and it passes the checks.
/// </summary>
/// <remarks>
/// A link that fails them is dropped and the launch goes ahead as an ordinary one: the user clicked
/// something that meant "open DodoSSH", and a window is still the right answer to that even when what
/// came with it cannot be trusted. See <see cref="SpelunkLink.TryParse"/> for what is checked.
/// </remarks>
private static SpelunkLink? LinkFrom(string[] args) =>
args.FirstOrDefault(SpelunkLink.LooksLikeOne) is { } candidate
&& SpelunkLink.TryParse(candidate, out var link)
? link
: null;
/// <summary>Makes this executable the handler for <c>dodossh://</c> links, where that is a registry key.</summary>
/// <remarks>
/// Failures are swallowed. This runs inside Velopack's install, and an installer that failed because a
/// convenience could not be set up would be the wrong trade — the application works without it, and the
/// only thing lost is Spelunk's button opening it.
/// </remarks>
private static void RegisterUrlScheme()
{
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
{
return;
}
try
{
WindowsUrlScheme.Register(executable);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// See the remarks.
}
}
/// <inheritdoc cref="RegisterUrlScheme" path="/remarks" />
private static void UnregisterUrlScheme()
{
if (!OperatingSystem.IsWindows() || Environment.ProcessPath is not { } executable)
{
return;
}
try
{
WindowsUrlScheme.Unregister(executable);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or System.Security.SecurityException)
{
// See the remarks on RegisterUrlScheme.
}
}
/// <summary> /// <summary>
/// Puts WebView2's user data folder beside the vault cache instead of beside the executable. /// Puts WebView2's user data folder beside the vault cache instead of beside the executable.
/// </summary> /// </summary>
@@ -0,0 +1,54 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Tells a drawn titlebar's double-click from a press that starts a drag.
/// </summary>
/// <remarks>
/// <para>
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
/// The two do not mix: <c>BeginMoveDrag</c> hands the press to the operating system's own move loop, which
/// eats the release — and a double tap is a gesture Avalonia assembles from presses <em>and</em> releases,
/// so the second click often never completed one and the window did not maximise. The convention it was
/// imitating is one nobody thinks about until it does not work.
/// </para>
/// <para>
/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
/// </para>
/// </remarks>
internal sealed class CaptionPresses
{
private ulong lastAt;
private Point lastWhere;
private bool waitingForASecond;
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
{
var settings = Application.Current?.PlatformSettings;
var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
var where = e.GetPosition(bar);
var second = e.ClickCount == 2
|| (waitingForASecond
&& e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
&& Math.Abs(where.X - lastWhere.X) <= size.Width
&& Math.Abs(where.Y - lastWhere.Y) <= size.Height);
// A double-click spends both presses: a third that follows quickly starts again rather than toggling
// the window straight back.
waitingForASecond = !second;
lastAt = e.Timestamp;
lastWhere = where;
return second;
}
}
@@ -59,7 +59,7 @@
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}" <Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" /> <Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -1,4 +1,5 @@
using Avalonia.Controls; using Avalonia.Controls;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
/// </remarks> /// </remarks>
internal sealed partial class ConfirmDeleteCard : UserControl internal sealed partial class ConfirmDeleteCard : UserControl
{ {
public ConfirmDeleteCard() => InitializeComponent(); public ConfirmDeleteCard()
{
InitializeComponent();
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
}
} }
@@ -4,7 +4,7 @@
Width="420" SizeToContent="Height" Width="420" SizeToContent="Height"
CanResize="False" ShowInTaskbar="False" CanResize="False" ShowInTaskbar="False"
WindowStartupLocation="CenterOwner" WindowStartupLocation="CenterOwner"
Background="{StaticResource Surface}" Background="{StaticResource Panel}"
Title="DodoSSH"> Title="DodoSSH">
<!-- <!--
@@ -32,7 +32,7 @@
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" /> <TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap" <TextBlock Foreground="{StaticResource TextDim}" FontSize="12" TextWrapping="Wrap"
x:Name="Explanation" /> x:Name="Explanation" />
<!-- <!--
@@ -39,7 +39,10 @@ public sealed partial class DevicePinWindow : Window
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary> /// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
public DevicePinWindow() public DevicePinWindow()
{ {
AvaloniaXamlLoader.Load(this); // InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
// fields below, and with Load the first of them was null — so this window threw on construction and
// no Linux PIN prompt was ever shown.
InitializeComponent();
Accept.Click += OnAccept; Accept.Click += OnAccept;
Cancel.Click += OnCancel; Cancel.Click += OnCancel;
@@ -253,8 +253,20 @@
</Grid> </Grid>
</Border> </Border>
<ScrollViewer Grid.Row="1"> <!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8"> <ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:HetznerRowViewModel"> <DataTemplate x:DataType="vm:HetznerRowViewModel">
<Border Classes="importrow" CornerRadius="8"> <Border Classes="importrow" CornerRadius="8">
@@ -290,8 +302,7 @@
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer>
</Grid> </Grid>
</Border> </Border>
+187 -17
View File
@@ -1,6 +1,7 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:dom="using:DodoSSH.Client.Domain"
xmlns:views="using:DodoSSH.Client.App.Views" xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.HostDrawer" x:Class="DodoSSH.Client.App.Views.HostDrawer"
x:DataType="vm:VaultViewModel"> x:DataType="vm:VaultViewModel">
@@ -385,6 +386,33 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!--
============ FORWARDS (read-only) ============
QUICK ACCESS's shape, and hidden when empty for the same reason: a card with nothing under it
answers a question nobody asked here.
-->
<Border Classes="section" IsVisible="{Binding SelectedHost.HasForwards}">
<StackPanel Spacing="6">
<TextBlock Classes="sectionhead" Text="Port forwards" Margin="0,0,0,2" />
<Button Classes="fieldrow" Command="{Binding EditSelectedHostCommand}">
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="13"
Foreground="{StaticResource TextFaint}" Width="16" VerticalAlignment="Top"
Margin="0,3,0,0" Text="&#xE8D4;" />
<ItemsControl Grid.Column="1" ItemsSource="{Binding SelectedHost.Host.PortForwards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dom:PortForwardRule">
<TextBlock Classes="mono" Text="{Binding Notation}" FontSize="12"
TextTrimming="CharacterEllipsis" Margin="0,0,0,2" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Grid>
</Button>
</StackPanel>
</Border>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." /> Text="Double-clicking the card does the same as CONNECT. The terminal opens as a tab in the strip above and stays there while you look at anything else." />
@@ -550,7 +578,8 @@
--> -->
<Grid ColumnDefinitions="*,8,78"> <Grid ColumnDefinitions="*,8,78">
<TextBox Grid.Column="0" Text="{Binding EditorUsername}" <TextBox Grid.Column="0" Text="{Binding EditorUsername}"
PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40" /> PlaceholderText="{Binding EditorUsernamePlaceholder}" Height="40"
IsEnabled="{Binding EditorAsksForLogin}" />
<NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}" <NumericUpDown Grid.Column="2" FontFamily="{StaticResource MonoFont}"
Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0" Value="{Binding EditorPort}" Minimum="1" Maximum="65535" FormatString="0"
ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}" ShowButtonSpinner="False" PlaceholderText="{Binding EditorPortPlaceholder}"
@@ -571,6 +600,7 @@
--> -->
<ComboBox ItemsSource="{Binding EditorAuthenticationChoices}" <ComboBox ItemsSource="{Binding EditorAuthenticationChoices}"
SelectedItem="{Binding EditorSelectedAuthentication}" SelectedItem="{Binding EditorSelectedAuthentication}"
IsEnabled="{Binding EditorAsksForLogin}"
HorizontalAlignment="Stretch" Height="40"> HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice"> <DataTemplate x:DataType="vm:AuthenticationChoice">
@@ -584,6 +614,15 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
<!--
Why the two controls above are greyed out for a host Spelunk signs in to: disabled rather
than hidden, so a login somebody chose is still there to see and comes back if the box in
the Spelunk card is unticked before saving. See VaultViewModel.EditorAsksForLogin.
-->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
IsVisible="{Binding !EditorAsksForLogin}"
Text="Spelunk signs you in to this host, so it needs no username, SSH key or password." />
<!-- <!--
Making a credential without leaving the host. The moment one is wanted is this one: somebody Making a credential without leaving the host. The moment one is wanted is this one: somebody
is deciding how a host authenticates and finds the password is not in the keychain yet, and is deciding how a host authenticates and finds the password is not in the keychain yet, and
@@ -636,16 +675,15 @@
<!-- <!--
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the ◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
title beside it rather than under it — but NOT to the mock's copy. The sentence stays title beside it rather than under it. The "(not wired up yet)" this title carried while the
exactly what it was, "(not wired up yet)" included: the design's own wording implies a relay client had no path to the relay is gone, because it has one: a relayed host is connected
this client can dial, and it cannot. See the remark under the hint below, which is unchanged through a ticket, a WebSocket and a loopback bridge. See docs/reaching-a-host-you-cannot-dial.md.
and still the reason this box says what it says.
--> -->
<Border CornerRadius="12" Background="{StaticResource Field}" <Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"> BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center"> <CheckBox IsChecked="{Binding EditorRelayEnabled}" VerticalContentAlignment="Center">
<TextBlock Text="Connect through the server relay (not wired up yet)" FontSize="13" <TextBlock Text="Connect through the server relay" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}" FontWeight="SemiBold" Foreground="{StaticResource Text}"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</CheckBox> </CheckBox>
@@ -655,19 +693,73 @@
to resolve the target, or it becomes an authenticated open proxy into the operator's to resolve the target, or it becomes an authenticated open proxy into the operator's
network. network.
◆ AND IT SAYS, FIRST, THAT TICKING IT BUYS NOTHING TODAY. The server half of the relay It also says the two things a user would otherwise find out by failing: the server,
is built and this client has no path to it: VaultViewModel dials the address directly not this machine, has to be able to reach the address — the relay is no help against a
whether this is ticked or not. So the box as it stood spent the one deliberate privacy deployment outside the target's network — and a relayed host does not fall back to
concession in the design — the address in plaintext columns — and delivered a dialling directly, so it connects only while the server is reachable.
connection that failed exactly as it had before, with nothing saying why.
Left tickable rather than disabled, because a host that already carries the flag has to
be able to lose it, and a disabled control would trap the concession on. See
docs/reaching-a-host-you-cannot-dial.md, which is the plan that makes this sentence
removable.
--> -->
<TextBlock Classes="hint" FontSize="11.5" <TextBlock Classes="hint" FontSize="11.5"
Text="Not built yet: this client always dials the host itself, so ticking this stores the address on the server and changes nothing about how the host is reached. When it does work, the relay will dial on your behalf — which is why the address and port have to be stored in plain text. Everything else about the host stays encrypted either way." /> Text="The server dials this host on your behalf, for a machine this computer cannot reach — so the server has to be able to reach it. That is why the address and port are stored on the server in plain text; everything else about the host stays encrypted. A relayed host connects only while you are online." />
</StackPanel>
</Border>
<!--
◆ THE SPELUNK CARD, the relay card's twin and its alternative: ticking one unticks the other,
because a host has one route. A host ticked here is reached through the Spelunk agent already
on the server, so it needs no open SSH port at all. The fields appear only when the box is
ticked, and the key is a keychain credential — picked, never typed here — so it is encrypted
and synced like every other secret. See VaultViewModel.Spelunk.cs.
-->
<Border CornerRadius="12" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12">
<StackPanel Spacing="6">
<CheckBox IsChecked="{Binding EditorThroughSpelunk}" VerticalContentAlignment="Center">
<TextBlock Text="Connect through Spelunk" FontSize="13"
FontWeight="SemiBold" Foreground="{StaticResource Text}"
TextWrapping="Wrap" />
</CheckBox>
<StackPanel Spacing="6" IsVisible="{Binding EditorThroughSpelunk}">
<TextBox Text="{Binding EditorSpelunkApi}" Height="36"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Text="{Binding EditorSpelunkServerId}" Height="36"
PlaceholderText="Spelunk server id" />
<!--
Saved Spelunk keys first (Settings → Security), each qualified by the one address it
goes to, then credentials whose password is a key. A deleted one keeps a placeholder, as
the authentication picker's does, so an unrelated edit cannot drop it. Typing an address
a key is saved for picks that key when nothing is picked yet.
-->
<TextBlock Classes="hint" FontSize="10.5" Text="Spelunk API key" />
<ComboBox ItemsSource="{Binding EditorSpelunkKeyChoices}"
SelectedItem="{Binding EditorSelectedSpelunkKey}"
HorizontalAlignment="Stretch" Height="40">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:AuthenticationChoice">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding Label}" />
<TextBlock Text="{Binding Qualifier}" Classes="hint" FontSize="11"
VerticalAlignment="Center" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<!--
Spelunk signs the user in: a key pair made in memory for each connection, its public
half sent with the ticket, and the agent's one-connection SSH server accepting only
that. Ticking it greys out the login above. See SpelunkRoute.SignInThroughSpelunk.
-->
<CheckBox IsChecked="{Binding EditorSpelunkSignIn}" VerticalContentAlignment="Center">
<TextBlock Text="Sign in through Spelunk (no SSH key needed)" FontSize="12.5"
Foreground="{StaticResource Text}" TextWrapping="Wrap" />
</CheckBox>
</StackPanel>
<!--
What the route asks for and what it costs, said where the decision is made: an Admin-role
Spelunk API key, sent to the address above and nowhere else, and a host that is reached
only while Spelunk and its agent are.
-->
<TextBlock Classes="hint" FontSize="11.5"
Text="Spelunk's agent on the server connects to its SSH server for you, so the server needs no open SSH port. Save your Spelunk API key (Admin role) under Settings → Security and choose it here, or keep it as a credential's password; it is sent only to the address above. Signing in through Spelunk logs you in as the account Spelunk names, with a key made for each connection and never kept. The hostname and port still name this machine's host key. Connects only while Spelunk and its agent are reachable." />
</StackPanel> </StackPanel>
</Border> </Border>
@@ -747,6 +839,84 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!--
============ PORT FORWARDS ============
Staged on VaultViewModel.EditorPortForwards the way QUICK ACCESS stages its pins, and saved
with the host. Each row is drawn in OpenSSH's own notation, because that is what anybody who has
used one reads fastest; the add row spells out which end the listening port is on instead,
because -L and -R are the two flags people get backwards.
No bind-address field, and that is not an omission: every listener binds loopback. A forward is
a field every member of a shared keychain can fill in, and one bound to 0.0.0.0 would hand the
remote's network to anybody who can reach this machine. See SshPortForwardSpec.
-->
<Border Classes="section">
<StackPanel Spacing="8">
<TextBlock Classes="sectionlabel" Text="PORT FORWARDS" />
<ItemsControl ItemsSource="{Binding EditorPortForwards}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><StackPanel Spacing="6" /></ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dom:PortForwardRule">
<Border Height="33" CornerRadius="9" Background="{StaticResource Field}"
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="8,0">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="14"
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="&#xE8D4;" />
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Notation}" FontSize="12.5"
Margin="8,0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Notation}" />
<Button Grid.Column="2" Classes="flat paneicon" Content="✕" Width="22" Height="22"
FontSize="11"
Command="{Binding $parent[ItemsControl].((vm:VaultViewModel)DataContext).RemoveEditorForwardCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Removes this forward" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ComboBox SelectedIndex="{Binding EditorNewForwardKind}" HorizontalAlignment="Stretch"
Height="36">
<ComboBoxItem Content="Local -L" />
<ComboBoxItem Content="Remote -R" />
<ComboBoxItem Content="Dynamic -D (SOCKS)" />
</ComboBox>
<!-- Which end each port is on, for the kind chosen: the thing -L and -R get backwards. -->
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="{Binding EditorNewForwardKindHint}" />
<Grid ColumnDefinitions="104,6,*,6,Auto">
<TextBox Grid.Column="0" FontFamily="{StaticResource MonoFont}"
Text="{Binding EditorNewForwardBindPort}"
PlaceholderText="{Binding EditorNewForwardBindHint}" Height="36"
ToolTip.Tip="{Binding EditorNewForwardBindHint}">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorForwardCommand}" />
</TextBox.KeyBindings>
</TextBox>
<TextBox Grid.Column="2" FontFamily="{StaticResource MonoFont}"
Text="{Binding EditorNewForwardTarget}"
IsVisible="{Binding EditorNewForwardHasTarget}"
PlaceholderText="host:port" Height="36">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddEditorForwardCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="4" Classes="ghost" Content="+" Width="36" Height="36" FontSize="15"
HorizontalContentAlignment="Center"
Command="{Binding AddEditorForwardCommand}" ToolTip.Tip="Adds this forward" />
</Grid>
<TextBlock Classes="hint" FontSize="11.5" TextWrapping="Wrap"
Text="Forwards start with every terminal opened to this host and stop when it closes. Everything listens on localhost only. A change here applies from the next connection." />
</StackPanel>
</Border>
</StackPanel> </StackPanel>
<!-- ============ THE GROUP EDITOR ============ --> <!-- ============ THE GROUP EDITOR ============ -->
@@ -45,7 +45,11 @@
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha 80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
variant of a surface; the pre-multiplied ones there are accent washes. variant of a surface; the pre-multiplied ones there are accent washes.
--> -->
<Border Background="#CC05050A"> <!--
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
that cannot be answered by clicking away should not be answerable by tabbing away either.
-->
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
<Panel> <Panel>
<!-- ============ UNKNOWN HOST KEY ============ --> <!-- ============ UNKNOWN HOST KEY ============ -->
@@ -83,7 +87,7 @@
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="TRUST AND CONNECT" <Button Classes="accent" Content="TRUST AND CONNECT"
Command="{Binding TrustHostKeyCommand}" /> Command="{Binding TrustHostKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" /> <Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -119,7 +123,7 @@
is not an answer, because this half of the control has none to give. Compare the accent button is not an answer, because this half of the control has none to give. Compare the accent button
above, which connects. above, which connects.
--> -->
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left" <Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
Command="{Binding RejectHostKeyCommand}" /> Command="{Binding RejectHostKeyCommand}" />
</StackPanel> </StackPanel>
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostKeyCard : UserControl internal sealed partial class HostKeyCard : UserControl
{ {
public HostKeyCard() => InitializeComponent(); public HostKeyCard() => InitializeComponent();
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
/// <remarks>
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> gives about every confirmation
/// and with more force here: an Enter meant for whatever was focused before the card appeared must not
/// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
/// this one is raised over a terminal that may be holding it natively — see
/// <c>MainWindow.OnHostKeyDecision</c>.
/// </remarks>
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
} }
+35 -14
View File
@@ -230,9 +230,13 @@
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
who has found this box should know about the one that also connects on Enter. who has found this box should know about the one that also connects on Enter.
One way, and passed back by the code-behind once the typing pauses: every change to the filter
rebuilds the board's sections and their cards, and a name typed at speed used to do that once a
letter. The view model still filters the moment it is told. See HostsScreen.OnFilterTyped.
--> -->
<Border Grid.Row="1" Margin="26,0,26,16"> <Border Grid.Row="1" Margin="26,0,26,16">
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10" <TextBox x:Name="HostFilter" Text="{Binding HostFilter, Mode=OneWay}" Height="40" CornerRadius="10"
FontFamily="{StaticResource MonoFont}" FontFamily="{StaticResource MonoFont}"
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" /> PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
</Border> </Border>
@@ -573,8 +577,9 @@
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}" SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set."> ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
<!-- Builds only the cards near the view; see VirtualizingWrapPanel. -->
<ListBox.ItemsPanel> <ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate> <ItemsPanelTemplate><views:VirtualizingWrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel> </ListBox.ItemsPanel>
<!-- <!--
@@ -683,7 +688,7 @@
TextTrimming never fires and the name would run on past the card's own TextTrimming never fires and the name would run on past the card's own
border instead of trimming. See the longer note this carried in v4. border instead of trimming. See the longer note this carried in v4.
--> -->
<Grid ColumnDefinitions="*,Auto,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17" <TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
FontWeight="Bold" Foreground="{StaticResource Text}" FontWeight="Bold" Foreground="{StaticResource Text}"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
@@ -708,17 +713,27 @@
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}" <Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
Width="9" Height="9" /> Width="9" Height="9" />
</StackPanel> </StackPanel>
</Grid>
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0" <!--
"not synced" and its kin sit on the address line. Beside the name, where it
used to be, it took seventy of the card's pixels from the one thing a card
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
both drew as "k8s…". The address can give the room up — it trims, and the
tooltip and the drawer both carry it whole.
-->
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
Foreground="{StaticResource TextGhost}"
TextTrimming="CharacterEllipsis" />
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
VerticalAlignment="Center"
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"> IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<TextBlock Text="{Binding Badge}" FontSize="9.5" /> <TextBlock Text="{Binding Badge}" FontSize="9.5" />
</Border> </Border>
</Grid> </Grid>
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
Foreground="{StaticResource TextGhost}"
TextTrimming="CharacterEllipsis" />
<!-- <!--
Which vault this host is in, and only when there is more than one to be Which vault this host is in, and only when there is more than one to be
in. It decides who else can see the host and where an edit goes back to, in. It decides who else can see the host and where an edit goes back to,
@@ -734,8 +749,13 @@
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom" <Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
Margin="0,8,0,0"> Margin="0,8,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" <!--
VerticalAlignment="Bottom"> A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
measures its children with infinite width, so the tags' WrapPanel below
never wrapped and ran past the card's edge; the last child of a DockPanel
gets the width that is actually left, and wraps in it.
-->
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
<!-- <!--
The pin badge: how many folders are pinned on this host, reading The pin badge: how many folders are pinned on this host, reading
@@ -743,8 +763,8 @@
property's own remarks. Only when there is at least one; a badge reading property's own remarks. Only when there is at least one; a badge reading
zero would be a fact nobody asked for. zero would be a fact nobody asked for.
--> -->
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2" <Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
IsVisible="{Binding HasPins}"> Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
<StackPanel Orientation="Horizontal" Spacing="3"> <StackPanel Orientation="Horizontal" Spacing="3">
<TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}" <TextBlock Text="&#xF10D;" FontFamily="{StaticResource IconFont}"
FontSize="11" Foreground="{StaticResource TextFaint}" /> FontSize="11" Foreground="{StaticResource TextFaint}" />
@@ -767,12 +787,13 @@
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2" <Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
Margin="0,0,4,4"> Margin="0,0,4,4">
<TextBlock Text="{Binding}" FontSize="11" <TextBlock Text="{Binding}" FontSize="11"
Foreground="{StaticResource TextFaint}" /> Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis" />
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</StackPanel> </DockPanel>
<!-- <!--
HostRowViewModel.LastConnectedText is filled from the connection log on HostRowViewModel.LastConnectedText is filled from the connection log on
@@ -106,6 +106,48 @@ internal sealed partial class HostsScreen : UserControl
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel); Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
WireTheBand(); WireTheBand();
HostFilter.TextChanged += OnFilterTyped;
}
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
private int typed;
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
/// <remarks>
/// <para>
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
/// </para>
/// <para>
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
/// change it makes to the box comes back here as the same text, which changes nothing.
/// </para>
/// </remarks>
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
private async Task PassTheFilterOnAsync(string text, int keystroke)
{
await Pause(FilterPause).ConfigureAwait(true);
// A later letter has its own wait, and it is the one that speaks.
if (keystroke != typed)
{
return;
}
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
{
vault.HostFilter = text;
}
} }
/// <summary>The band's own wiring, and the two keys that go with a set.</summary> /// <summary>The band's own wiring, and the two keys that go with a set.</summary>
@@ -122,6 +164,13 @@ internal sealed partial class HostsScreen : UserControl
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen. // touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
Board.KeyDown += OnGridKey; Board.KeyDown += OnGridKey;
// The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See
// OnCardSelectionChanged for why this is not simply a two-way binding.
Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged);
// Tunnelled, because a section's list answers Enter itself and nothing bubbles past it.
Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel);
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged // ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps // out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
// between sections and below the last one belong to the stack around them. Tunnelled for the reason // between sections and below the last one belong to the stack around them. Tunnelled for the reason
@@ -315,6 +364,16 @@ internal sealed partial class HostsScreen : UserControl
return; return;
} }
// An arrow with the board itself focused — which is where this screen puts the keyboard, see
// KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever
// reaches a section's list, since none of them has the keyboard until a card has been clicked.
if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board))
{
e.Handled = EnterTheCards(vault);
return;
}
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries) if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
{ {
var order = vault.HostBoardOrder.ToList(); var order = vault.HostBoardOrder.ToList();
@@ -347,6 +406,80 @@ internal sealed partial class HostsScreen : UserControl
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>. /// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
/// </para> /// </para>
/// </remarks> /// </remarks>
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
/// <remarks>
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
/// while cards are ticked, where the selection is not what the set is about.
/// </remarks>
private void OnCardEnter(object? sender, KeyEventArgs e)
{
if (e.Key is not Key.Enter
|| e.KeyModifiers != KeyModifiers.None
|| Vault is not { IsChoosingHosts: false } vault
|| RowUnder(e.Source) is not HostRowViewModel row)
{
return;
}
vault.SelectedHost = row;
_ = vault.ConnectCommand.ExecuteAsync(null);
e.Handled = true;
}
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
/// <remarks>
/// <para>
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
/// right-click handler exists to prevent.
/// </para>
/// <para>
/// ◆ Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
/// </para>
/// </remarks>
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (Vault is not { } vault
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|| ReferenceEquals(row, vault.SelectedHost))
{
return;
}
if (vault.IsChoosingHosts)
{
vault.ClearHostChoiceCommand.Execute(null);
}
anchor = row;
vault.SelectedHost = row;
}
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
private bool EnterTheCards(VaultViewModel vault)
{
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
if (card?.DataContext is not HostRowViewModel row)
{
return false;
}
vault.SelectedHost = row;
anchor = row;
return card.Focus(NavigationMethod.Directional);
}
private void OnBandPressed(object? sender, PointerPressedEventArgs e) private void OnBandPressed(object? sender, PointerPressedEventArgs e)
{ {
EndBand(); EndBand();
@@ -131,8 +131,20 @@
</Grid> </Grid>
</Border> </Border>
<ScrollViewer Grid.Row="1"> <!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8"> <ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ImportRowViewModel"> <DataTemplate x:DataType="vm:ImportRowViewModel">
<Border Classes="importrow" CornerRadius="8"> <Border Classes="importrow" CornerRadius="8">
@@ -161,8 +173,7 @@
</Border> </Border>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer>
</Grid> </Grid>
</Border> </Border>
@@ -256,7 +256,8 @@
</Border> </Border>
<!-- ============ The table ============ --> <!-- ============ The table ============ -->
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"> <Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"
Grid.IsSharedSizeScope="True">
<Border Grid.Row="0" Padding="16,0" Height="44" <Border Grid.Row="0" Padding="16,0" Height="44"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"> BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
@@ -275,12 +276,24 @@
item this application stores carries a timestamp; see the file-level remark above and item this application stores carries a timestamp; see the file-level remark above and
docs/design-import-gaps.md. docs/design-import-gaps.md.
--> -->
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6" <!--
VAULT is sized to what is in it, and shared between this heading and every row so the two agree:
with one vault no row names one, and a fixed column was a heading over a stripe of nothing.
-->
<Grid Grid.Row="1" Margin="0,10,16,6"
IsVisible="{Binding HasVaultItems}"> IsVisible="{Binding HasVaultItems}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2" />
<ColumnDefinition Width="3*" />
<ColumnDefinition Width="1.1*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
<ColumnDefinition Width="1.2*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1" <TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
Margin="12,0,8,0" /> Margin="12,0,8,0" />
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" /> <TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" /> <TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
Margin="0,0,16,0" IsVisible="{Binding ShowsVaultColumn}" />
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" /> <TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
</Grid> </Grid>
@@ -290,18 +303,31 @@
Margin="8,0,8,10"> Margin="8,0,8,10">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultItemRowViewModel"> <DataTemplate x:DataType="vm:VaultItemRowViewModel">
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0"> <Grid Height="40" Margin="6,0,10,0">
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0" <Grid.ColumnDefinitions>
VerticalAlignment="Center"> <ColumnDefinition Width="2" />
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15" <ColumnDefinition Width="3*" />
<ColumnDefinition Width="1.1*" />
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
<ColumnDefinition Width="1.2*" />
</Grid.ColumnDefinitions>
<!--
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
columns beside it.
-->
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}" Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
VerticalAlignment="Center" /> VerticalAlignment="Center" Margin="0,0,10,0" />
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium" <TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" /> FontWeight="Medium" Foreground="{StaticResource Text}"
</StackPanel> TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
</Grid>
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5" <TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" /> Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" TextTrimming="CharacterEllipsis" />
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" Margin="0,0,16,0"
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}"> VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" FontSize="10" /> <TextBlock Text="{Binding VaultBadge}" FontSize="10" />
</Border> </Border>
+15 -4
View File
@@ -332,7 +332,7 @@
a sidebar naming no host would be furniture with nothing to say. Collapsing frees the a sidebar naming no host would be furniture with nothing to say. Collapsing frees the
"Auto" column it sits in, so the pane column takes the width back. "Auto" column it sits in, so the pane column takes the width back.
--> -->
<views:SessionSidebar Grid.Column="1" ShowsSnips="True" <views:SessionSidebar Grid.Column="1" ShowsSnips="True" ShowsForwards="True"
IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" /> IsVisible="{Binding ShowsQuickAccessSidebar, FallbackValue=False}" />
</Grid> </Grid>
</Border> </Border>
@@ -383,7 +383,12 @@
not replayed. See MainWindow.axaml.cs. not replayed. See MainWindow.axaml.cs.
--> -->
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}" <TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
PlaceholderText="https://dodossh.example" /> PlaceholderText="https://dodossh.example">
<!-- Enter signs in, as it unlocks on the unlock card. -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}" <Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
@@ -395,8 +400,14 @@
<TextBlock Classes="heading" Text="Choose a keychain passphrase" /> <TextBlock Classes="heading" Text="Choose a keychain passphrase" />
<TextBlock Classes="hint" <TextBlock Classes="hint"
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." /> Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" /> <TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" /> PasswordChar="•" />
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}" <Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" /> IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
<TextBlock Classes="hint" Text="{Binding StatusMessage}" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" />
+165 -16
View File
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
private bool wasUnlocked; private bool wasUnlocked;
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
private ShellState? closedState;
private bool wasAskingAboutHostKey;
public MainWindow() public MainWindow()
{ {
InitializeComponent(); InitializeComponent();
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
{ {
ShellState.Locked => UnlockPane.PassphraseBox, ShellState.Locked => UnlockPane.PassphraseBox,
ShellState.NeedsServer => ServerUrlBox, ShellState.NeedsServer => ServerUrlBox,
ShellState.NeedsEnrollment => NewPassphraseBox,
_ => this, _ => this,
}; };
@@ -226,33 +232,96 @@ internal sealed partial class MainWindow : Window
size.Execute(null); size.Execute(null);
e.Handled = true; e.Handled = true;
} }
// A host-key decision before the palette, because it is drawn over the palette: Escape is CANCEL or
// CLOSE, both of which refuse — never the answer that connects. See HostKeyCard.SafeAnswer.
else if (e.Key == Key.Escape && viewModel.IsHostKeyDecisionShowing)
{
viewModel.Vault?.RejectHostKeyCommand.Execute(null);
e.Handled = true;
}
else if (viewModel.IsSearching) else if (viewModel.IsSearching)
{ {
Palette.HandleKey(e); Palette.HandleKey(e);
} }
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
// already follows one branch up. Checked after the palette rather than before it: the two states
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
// settings does not open the palette — but an Escape while both were somehow true should close the
// thing drawn on top, which is the palette.
//
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
// "Back to application" while IsImportOpen is true.
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
{ {
viewModel.CloseImportCommand.Execute(null);
e.Handled = true;
}
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
{
viewModel.LeaveSettingsCommand.Execute(null);
e.Handled = true; e.Handled = true;
} }
base.OnKeyDown(e); base.OnKeyDown(e);
} }
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
/// <remarks>
/// <para>
/// One rule throughout — closest thing first — so the order of these arms is the order things are drawn
/// over one another. A confirmation before anything, because it is a question in front of whatever page
/// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings.
/// </para>
/// <para>
/// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already
/// follows in the caller. Checked after the palette rather than before it: the two states are mutually
/// exclusive in practice — opening the palette does not enter settings mode and entering settings does
/// not open the palette — but an Escape while both were somehow true should close the thing drawn on
/// top, which is the palette.
/// </para>
/// <para>
/// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own
/// back button follows by showing "Back to preferences" rather than "Back to application" while
/// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so
/// it goes first and settings stays.
/// </para>
/// <para>
/// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its
/// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer.
/// </para>
/// </remarks>
private static bool CloseTheNearestThing(MainWindowViewModel viewModel)
{
if (AnswerNoToTheQuestionShowing(viewModel))
{
return true;
}
var close = viewModel switch
{
{ IsImportOpen: true } => viewModel.CloseImportCommand,
{ IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand,
{ IsSettingsMode: true } => viewModel.LeaveSettingsCommand,
{ IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault }
=> vault.CloseDrawerCommand,
_ => null,
};
close?.Execute(null);
return close is not null;
}
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
/// <remarks>
/// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a
/// question about destroying something. A deletion question left armed on a screen somebody navigated away
/// from is cancelled by it too, which is harmless — it is the same answer the question would get if it
/// were on screen — and cheaper than deciding which of the six places that card is drawn is showing.
/// </remarks>
private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel)
{
if (viewModel.IsConfirmingSignOut)
{
viewModel.CancelSignOutCommand.Execute(null);
return true;
}
if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault })
{
vault.CancelDeleteCommand.Execute(null);
return true;
}
return false;
}
/// <summary> /// <summary>
/// The text-size chords, when the terminal is not the thing hearing them. /// The text-size chords, when the terminal is not the thing hearing them.
/// </summary> /// </summary>
@@ -300,6 +369,15 @@ internal sealed partial class MainWindow : Window
Terminal.Source = viewModel.TerminalPageUrl; Terminal.Source = viewModel.TerminalPageUrl;
wasUnlocked = viewModel.IsUnlocked; wasUnlocked = viewModel.IsUnlocked;
closedState = null;
wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing;
// The shell may have reached its card before this window was given it, and then no change would
// ever say so.
if (!viewModel.IsUnlocked)
{
FocusClosedVaultCard(viewModel);
}
viewModel.TerminalSessionOpened += OnTerminalSessionOpened; viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
viewModel.TerminalFocusRequested += OnTerminalFocusRequested; viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
@@ -358,6 +436,10 @@ internal sealed partial class MainWindow : Window
OnSelectedTabChanged(viewModel); OnSelectedTabChanged(viewModel);
break; break;
case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
OnHostKeyDecision(viewModel);
break;
default: default:
break; break;
} }
@@ -373,10 +455,77 @@ internal sealed partial class MainWindow : Window
{ {
ReleaseKeyboardTo(ClosedVaultKeyboardHome); ReleaseKeyboardTo(ClosedVaultKeyboardHome);
} }
else if (!unlocked && viewModel.State != closedState)
{
FocusClosedVaultCard(viewModel);
}
closedState = unlocked ? null : viewModel.State;
wasUnlocked = unlocked; wasUnlocked = unlocked;
} }
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
/// <remarks>
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
/// raised for every state change, and the state does not change while they type. Posted, because the card
/// is revealed by a binding the same change has not reached yet.
/// </remarks>
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
{
closedState = viewModel.State;
var home = ClosedVaultKeyboardHome;
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
}
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
/// <remarks>
/// <para>
/// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose
/// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and
/// Tab walked the page under it. Hence <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
/// posted, because the binding that reveals the card has not run yet.
/// </para>
/// <para>
/// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must
/// move once per question, not once per raise. On the way out the keyboard goes where the window would
/// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens.
/// </para>
/// </remarks>
private void OnHostKeyDecision(MainWindowViewModel viewModel)
{
var asking = viewModel.IsHostKeyDecisionShowing;
if (asking == wasAskingAboutHostKey)
{
return;
}
wasAskingAboutHostKey = asking;
Dispatcher.UIThread.Post(
() =>
{
if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking)
{
return;
}
if (asking)
{
ReleaseKeyboardTo(HostKeyPane.SafeAnswer);
}
else
{
OnShowingSomethingElse(viewModel);
}
},
DispatcherPriority.Loaded);
}
/// <remarks> /// <remarks>
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected /// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
/// to start typing into immediately — but the palette does that for itself when it becomes visible, /// to start typing into immediately — but the palette does that for itself when it becomes visible,
@@ -0,0 +1,81 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Threading;
using Avalonia.VisualTree;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// Hands the keyboard to a confirmation's harmless answer when the question appears.
/// </summary>
/// <remarks>
/// <para>
/// For the questions drawn in place on a page — the deletion and the sign-out confirmations — which used to
/// appear with the keyboard still on the button that asked them, or nowhere. Tab then walked the page behind
/// the question, Enter did nothing useful, and somebody working from the keyboard had to reach for the mouse
/// to say no.
/// </para>
/// <para>
/// ◆ <b>The harmless answer, never the one the question is about.</b> Enter on a focused button presses it,
/// so the button that takes the keyboard is the one an accidental Enter is allowed to press: CANCEL. Putting it
/// on DELETE or SIGN OUT AND DELETE would make two keystrokes — the one that asked and the one after — a way to
/// destroy something without reading the sentence in between.
/// </para>
/// <para>
/// Each card follows its own question rather than the window following all of them, because the same card is
/// drawn in several places and only the one somebody can see should take the keyboard — which the posted
/// check on <c>IsEffectivelyVisible</c> decides, after the binding that reveals it has run. Subscribed only
/// while the card is in the tree: settings pages are rebuilt as they are visited, and a card that subscribed
/// for good would leave one handler on the long-lived view model per visit.
/// </para>
/// </remarks>
internal static class SafeAnswerFocus
{
internal static void Follow(Control card, string question, Func<Control> safeAnswer)
{
INotifyPropertyChanged? watched = null;
void OnChanged(object? sender, PropertyChangedEventArgs e)
{
if (!string.Equals(e.PropertyName, question, StringComparison.Ordinal))
{
return;
}
Dispatcher.UIThread.Post(
() =>
{
if (safeAnswer() is { IsEffectivelyVisible: true } answer)
{
answer.Focus();
}
},
DispatcherPriority.Loaded);
}
void Watch(INotifyPropertyChanged? next)
{
if (watched is not null)
{
watched.PropertyChanged -= OnChanged;
}
watched = next;
if (watched is not null)
{
watched.PropertyChanged += OnChanged;
}
}
card.AttachedToVisualTree += (_, _) => Watch(card.DataContext as INotifyPropertyChanged);
card.DetachedFromVisualTree += (_, _) => Watch(null);
card.DataContextChanged += (_, _) =>
{
if (card.IsAttachedToVisualTree())
{
Watch(card.DataContext as INotifyPropertyChanged);
}
};
}
}
@@ -39,6 +39,22 @@
is showing without knowing anything about the state — and the pane beside it grows into what is freed. is showing without knowing anything about the state — and the pane beside it grows into what is freed.
--> -->
<!--
A forward's state word: green only while it is listening, which is the one status Live is for, and the
danger text colour for a forward that could not start. OFF and STARTING stay faint.
-->
<UserControl.Styles>
<Style Selector="TextBlock.fwdstate">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
</Style>
<Style Selector="TextBlock.fwdstate.live">
<Setter Property="Foreground" Value="{StaticResource Live}" />
</Style>
<Style Selector="TextBlock.fwdstate.failed">
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
</Style>
</UserControl.Styles>
<Panel> <Panel>
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ --> <!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
@@ -142,6 +158,64 @@
</StackPanel> </StackPanel>
</Button> </Button>
<!-- ============ FORWARDS (the terminal surface only) ============ -->
<!--
The selected tab's forwards: the host's saved ones, as they run over this session. Each row is
its own switch, and a failed one says why under its notation — a port already taken, most often
by a second tab to the same host running the same forwards. Nothing here edits a forward; the add
row goes to the host's editor, where they are saved, as Pin folder does for QUICK ACCESS.
No byte counts, which the design draws. SSH.NET keeps no count of what a forward carries, so a
number here would be invented.
-->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsForwards}">
<TextBlock Classes="label" Text="FORWARDS" FontSize="10" Margin="8,0" />
<ItemsControl ItemsSource="{Binding SelectedTab.Forwards}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:PortForwardViewModel">
<!--
The reason sits under the row rather than inside it: a sidebar row is a fixed 33 pixels, and
a refusal is a sentence. Indented to the notation, so it reads as belonging to the row.
-->
<StackPanel>
<Button Classes="sidebarrow" Command="{Binding ToggleCommand}"
ToolTip.Tip="{Binding Route}">
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Text="&#xE8D4;"
FontFamily="{StaticResource IconFont}" FontSize="15"
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
Margin="0,0,10,0" />
<TextBlock Grid.Column="1" FontFamily="{StaticResource MonoFont}" FontSize="12.5"
Text="{Binding Notation}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Classes="fwdstate" FontFamily="Montserrat"
FontWeight="SemiBold" FontSize="9" LetterSpacing="1"
VerticalAlignment="Center" Margin="8,0,0,0"
Text="{Binding StateText}"
Classes.live="{Binding IsListening}" Classes.failed="{Binding IsFailed}" />
</Grid>
</Button>
<TextBlock FontSize="11" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
Margin="37,0,12,4" Text="{Binding Detail}"
IsVisible="{Binding Detail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Classes="sidebaradd" Command="{Binding AddForwardFromSidebarCommand}"
ToolTip.Tip="Opens the active tab's host for editing, where its forwards are saved. A new forward starts with the next connection.">
<StackPanel Orientation="Horizontal" Spacing="10">
<TextBlock Text="&#xE145;" FontFamily="{StaticResource IconFont}" FontSize="14"
VerticalAlignment="Center" />
<TextBlock Text="Add forward" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<!-- ============ SNIPS (the terminal surface only) ============ --> <!-- ============ SNIPS (the terminal surface only) ============ -->
<StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}"> <StackPanel Spacing="6" Margin="0,16,0,0" IsVisible="{Binding #Root.ShowsSnips}">
@@ -19,6 +19,10 @@ internal sealed partial class SessionSidebar : UserControl
internal static readonly StyledProperty<bool> ShowsSnipsProperty = internal static readonly StyledProperty<bool> ShowsSnipsProperty =
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsSnips)); AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsSnips));
/// <summary>Whether the FORWARDS section draws. The terminal's own, for the reason SNIPS is.</summary>
internal static readonly StyledProperty<bool> ShowsForwardsProperty =
AvaloniaProperty.Register<SessionSidebar, bool>(nameof(ShowsForwards));
public SessionSidebar() => InitializeComponent(); public SessionSidebar() => InitializeComponent();
internal bool ShowsSnips internal bool ShowsSnips
@@ -26,4 +30,10 @@ internal sealed partial class SessionSidebar : UserControl
get => GetValue(ShowsSnipsProperty); get => GetValue(ShowsSnipsProperty);
set => SetValue(ShowsSnipsProperty, value); set => SetValue(ShowsSnipsProperty, value);
} }
internal bool ShowsForwards
{
get => GetValue(ShowsForwardsProperty);
set => SetValue(ShowsForwardsProperty, value);
}
} }
@@ -76,6 +76,13 @@
</StackPanel> </StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="18" VerticalAlignment="Center">
<!--
How many of the selected tab's forwards are listening, and nothing when none are. The terminal's
own, for the reason UTF-8 is: an SFTP connection runs no forwards.
-->
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SelectedTab.ForwardsSummary}"
IsVisible="{Binding #Root.ShowsEncoding}" />
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}" <TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextGhost}"
VerticalAlignment="Center" Text="{Binding SessionElapsedText}" VerticalAlignment="Center" Text="{Binding SessionElapsedText}"
IsVisible="{Binding SessionElapsedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" /> IsVisible="{Binding SessionElapsedText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
@@ -86,7 +86,14 @@
Classes.connecting="{Binding IsConnecting}" Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}" Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" /> VerticalAlignment="Center" />
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" /> <!--
Bounded. A label is whatever the host was called, or the address typed into the palette,
and one long one used to push every tab after it and the "+" past the strip's edge. The
whole of it is on the label's own tooltip; the tab's is the address, as before.
-->
<TextBlock Text="{Binding Label}" VerticalAlignment="Center"
MaxWidth="200" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Label}" />
<Button Classes="flat close inline" Width="16" Height="16" Padding="0" <Button Classes="flat close inline" Width="16" Height="16" Padding="0"
VerticalAlignment="Center" VerticalAlignment="Center"
@@ -136,7 +136,7 @@
<Border Classes="settingscard"> <Border Classes="settingscard">
<StackPanel Margin="24,20" Spacing="10"> <StackPanel Margin="24,20" Spacing="10">
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." /> Text="Unlocking with this machine's device key moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." /> Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
@@ -1,8 +1,10 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels" xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage" x:Class="DodoSSH.Client.App.Views.SettingsSecurityPage"
x:DataType="vm:MainWindowViewModel"> x:DataType="vm:MainWindowViewModel"
x:Name="Root">
<!-- <!--
v5c: Security, against Settings-Security.dc.html. v5c: Security, against Settings-Security.dc.html.
@@ -24,6 +26,14 @@
on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving; on purpose — known-hosts is a MAIN-chrome screen, and there is no honest way to show it without leaving;
ShowScreenCommand already does that for any target that is not Preferences or Vaults. ShowScreenCommand already does that for any target that is not Preferences or Vaults.
SPELUNK is where a Spelunk API key is filled in: an address and a key, saved as a Spelunk key item in the
personal vault (VaultViewModel.SaveSpelunkKeyCommand) and synced like every secret. The address is the
point — a key saved here is only ever sent there, and links from that Spelunk connect straight away
because saving it is the user saying so. A second key for an address already saved replaces the first.
The key box is a password box and is emptied the moment the key is in the keychain; the list below it
shows the name, the address and how many hosts use each key, never the key. Deleting goes through the
same ConfirmDeleteCard every other deletion does, with a count of the hosts that would stop connecting.
RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean RECENT SECURITY EVENTS is the link row alone, not the design's own list of rows. A real list would mean
building a filtered read over LogsViewModel's activity and connection logs — which entries count as building a filtered read over LogsViewModel's activity and connection logs — which entries count as
"security" is itself a judgement call the design does not resolve — and the honest link is complete on "security" is itself a judgement call the design does not resolve — and the honest link is complete on
@@ -36,19 +46,21 @@
<TextBlock Classes="settingstitle" Text="Security" /> <TextBlock Classes="settingstitle" Text="Security" />
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20"> <Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
<StackPanel Orientation="Horizontal" Spacing="16"> <!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F"> <Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
Margin="0,0,16,0" VerticalAlignment="Top">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE897;" FontSize="20" <TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE897;" FontSize="20"
Foreground="{StaticResource Live}" Foreground="{StaticResource Live}"
HorizontalAlignment="Center" VerticalAlignment="Center" /> HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border> </Border>
<StackPanel Spacing="6" VerticalAlignment="Center"> <StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2" <TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
Foreground="{StaticResource Text}" /> Foreground="{StaticResource Text}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." /> Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
</StackPanel> </StackPanel>
</StackPanel> </Grid>
</Border> </Border>
<TextBlock Classes="settingssection" Text="UNLOCKING" /> <TextBlock Classes="settingssection" Text="UNLOCKING" />
@@ -58,15 +70,15 @@
<Border Classes="settingsrow last"> <Border Classes="settingsrow last">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640"> <StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" /> <TextBlock Classes="settingsrowtitle" Text="{Binding DeviceUnlock.Name}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." Text="{Binding DeviceUnlock.Offer}"
IsVisible="{Binding CanRegisterDevice}" /> IsVisible="{Binding CanRegisterDevice}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation." Text="{Binding DeviceUnlock.Registered}"
IsVisible="{Binding CanForgetDevice}" /> IsVisible="{Binding CanForgetDevice}" />
<TextBlock Classes="settingsrowcaption" <TextBlock Classes="settingsrowcaption"
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key." Text="{Binding DeviceUnlock.Unavailable}"
IsVisible="{Binding HasNoDeviceKeyOption}" /> IsVisible="{Binding HasNoDeviceKeyOption}" />
</StackPanel> </StackPanel>
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER" <Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
@@ -101,6 +113,86 @@
</StackPanel> </StackPanel>
</Border> </Border>
<TextBlock Classes="settingssection" Text="SPELUNK" />
<Border Classes="settingscard">
<StackPanel>
<Border Classes="settingsrow">
<StackPanel Spacing="10">
<StackPanel Spacing="6" MaxWidth="640" HorizontalAlignment="Left">
<TextBlock Classes="settingsrowtitle" Text="Spelunk API key" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="Hosts reached through Spelunk use this key, and links from that Spelunk connect straight away. It is kept encrypted in your personal vault, syncs to your other devices, and is only ever sent to the address below. Use a key with the Admin role." />
</StackPanel>
<Grid ColumnDefinitions="*,*,Auto" ColumnSpacing="10"
IsEnabled="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<TextBox Grid.Column="0" Height="38" Text="{Binding Vault.SpelunkKeyFormApi}"
PlaceholderText="Spelunk API address (https://api.…)" />
<TextBox Grid.Column="1" Height="38" PasswordChar="•" Text="{Binding Vault.SpelunkKeyFormKey}"
PlaceholderText="API key (spelunk_…)">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding Vault.SaveSpelunkKeyCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Grid.Column="2" Classes="accent" Height="38" Content="SAVE KEY"
Command="{Binding Vault.SaveSpelunkKeyCommand}"
IsEnabled="{Binding !Vault.IsBusy, FallbackValue=False}" />
</Grid>
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
Text="{Binding Vault.SpelunkKeyFormNote}"
IsVisible="{Binding Vault.HasSpelunkKeyFormNote, FallbackValue=False}" />
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNull}}"
Text="Unlock your keychain to save a Spelunk key." />
</StackPanel>
</Border>
<!-- The delete confirmation, in place, as the tags and groups pages draw it. -->
<Border Margin="24,12" Padding="20" CornerRadius="12"
Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
BorderThickness="1"
IsVisible="{Binding Vault.PendingDeletion, Converter={x:Static ObjectConverters.IsNotNull}, FallbackValue=False}">
<views:ConfirmDeleteCard DataContext="{Binding Vault}" />
</Border>
<ItemsControl ItemsSource="{Binding Vault.SpelunkKeys}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:SpelunkKeyRowViewModel">
<Border Classes="settingsrow" Padding="24,14">
<Grid ColumnDefinitions="*,Auto,Auto" ColumnSpacing="12">
<StackPanel Grid.Column="0" Spacing="4" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="settingsrowtitle" Text="{Binding Label}" />
<Border Classes="chip" IsVisible="{Binding HasVaultBadge}" VerticalAlignment="Center">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
<TextBlock Classes="hint" FontSize="11" Text="{Binding Badge}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="mono" FontSize="11.5" Foreground="{StaticResource TextFaint}"
Text="{Binding ApiBase}" />
</StackPanel>
<TextBlock Grid.Column="1" Classes="settingsrowcaption" VerticalAlignment="Center"
Text="{Binding UsedBy}" />
<Button Grid.Column="2" Classes="paneicon danger" Width="30" Height="30" FontSize="13"
VerticalAlignment="Center"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.DeleteSpelunkKeyRowCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="Delete this key. Hosts that reach Spelunk with it stop connecting until they name another.">
<TextBlock FontFamily="{StaticResource IconFont}" Text="&#xE872;" />
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" /> <TextBlock Classes="settingssection" Text="RECENT SECURITY EVENTS" />
<Border Classes="settingscard" Margin="0,12,0,40"> <Border Classes="settingscard" Margin="0,12,0,40">
@@ -2,7 +2,9 @@ using Avalonia.Controls;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
/// <summary>Settings mode's Security page: the E2E explainer, Windows Hello, and approved host keys.</summary> /// <summary>
/// Settings mode's Security page: the E2E explainer, Windows Hello, approved host keys, and Spelunk API keys.
/// </summary>
internal sealed partial class SettingsSecurityPage : UserControl internal sealed partial class SettingsSecurityPage : UserControl
{ {
public SettingsSecurityPage() => InitializeComponent(); public SettingsSecurityPage() => InitializeComponent();
@@ -81,11 +81,15 @@
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}"> IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
<StackPanel> <StackPanel>
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10"> <!--
No VAULT column, although the design draws one: TagRowViewModel does not carry which vault a tag
lives in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark), so
the column was a heading over nothing on every row.
-->
<Grid ColumnDefinitions="1.4*,2*,Auto" Margin="20,14,20,10">
<TextBlock Grid.Column="0" Classes="label" Text="TAG" /> <TextBlock Grid.Column="0" Classes="label" Text="TAG" />
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" /> <TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" /> <TextBlock Grid.Column="2" Classes="label" Text=" " />
<TextBlock Grid.Column="3" Classes="label" Text=" " />
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding Vault.Tags}"> <ItemsControl ItemsSource="{Binding Vault.Tags}">
@@ -97,7 +101,7 @@
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TagRowViewModel"> <DataTemplate x:DataType="vm:TagRowViewModel">
<Border Classes="settingsrow last" Padding="20,10"> <Border Classes="settingsrow last" Padding="20,10">
<Grid ColumnDefinitions="1.4*,*,*,Auto"> <Grid ColumnDefinitions="1.4*,2*,Auto">
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center"> <Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding Label}" /> <TextBlock Text="{Binding Label}" />
@@ -107,14 +111,7 @@
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding Description}" /> Text="{Binding Description}" />
<!-- <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
so there is no second vault this column could honestly name.
-->
<TextBlock Grid.Column="2" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
<Button Classes="paneicon" Width="30" Height="30" FontSize="13" <Button Classes="paneicon" Width="30" Height="30" FontSize="13"
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}" Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag."> CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
@@ -26,7 +26,7 @@
--> -->
<Border Height="53" Background="{StaticResource DeepChrome}" <Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised"> PointerPressed="OnDrag">
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0"> <Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
{ {
public SettingsTitleBar() => InitializeComponent(); public SettingsTitleBar() => InitializeComponent();
private readonly CaptionPresses presses = new();
private Window? Host => TopLevel.GetTopLevel(this) as Window; private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary> /// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
return; return;
} }
// Decided on the press, before the drag would take it: see CaptionPresses.
if (presses.IsSecond(e, this))
{
OnToggleMaximised(sender, e);
e.Handled = true;
return;
}
Host?.BeginMoveDrag(e); Host?.BeginMoveDrag(e);
} }
@@ -31,6 +42,7 @@ internal sealed partial class SettingsTitleBar : UserControl
} }
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary> /// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e) private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{ {
if (Host is not { } window) if (Host is not { } window)
@@ -55,7 +55,7 @@
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="danger" Content="SIGN OUT AND DELETE" <Button Classes="danger" Content="SIGN OUT AND DELETE"
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" /> Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" /> <Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
</StackPanel> </StackPanel>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap" <TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
@@ -1,9 +1,15 @@
using Avalonia.Controls; using Avalonia.Controls;
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views; namespace DodoSSH.Client.App.Views;
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary> /// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
internal sealed partial class SignOutCard : UserControl internal sealed partial class SignOutCard : UserControl
{ {
public SignOutCard() => InitializeComponent(); public SignOutCard()
{
InitializeComponent();
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
}
} }
@@ -167,8 +167,12 @@
TextAlignment="Center" MaxWidth="360" TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" /> IsVisible="{Binding !HasVisible}" />
<!--
Only with a snippet chosen. Three greyed buttons under a list nobody has clicked in read as
something broken rather than something waiting, and the pane on the right already says what to do.
-->
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}" <Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
BorderThickness="0,1,0,0"> BorderThickness="0,1,0,0" IsVisible="{Binding HasSelection}">
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}" <Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
IsEnabled="{Binding HasSelection}" /> IsEnabled="{Binding HasSelection}" />
+3 -2
View File
@@ -9,8 +9,9 @@
The design puts five things here: the active host and the bastion it is reached through, the open port The design puts five things here: the active host and the bastion it is reached through, the open port
forwards, the SFTP transfer count, an auto-lock countdown, and the last sync time. Four of those five forwards, the SFTP transfer count, an auto-lock countdown, and the last sync time. Four of those five
have nothing behind them — there is no jump-host routing, no port forwarding, no file transfer and no had nothing behind them when this was written — there was no jump-host routing, no port forwarding, no
idle auto-lock — so what is left is the active session, the vault's last message, and the shortcut hint. file transfer and no idle auto-lock — so what is left is the active session, the vault's last message,
and the shortcut hint. Forwards have since been built, and are counted on SessionStatusBar instead.
The last message is the load-bearing one, and it is why this bar is worth having at all: the vault's The last message is the load-bearing one, and it is why this bar is worth having at all: the vault's
status line used to be a cramped label in the account bar, and everything the application has to say status line used to be a cramped label in the account bar, and everything the application has to say
+1 -1
View File
@@ -32,7 +32,7 @@
--> -->
<Border Height="53" Background="{StaticResource DeepChrome}" <Border Height="53" Background="{StaticResource DeepChrome}"
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised"> PointerPressed="OnDrag">
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0"> <Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
+13 -1
View File
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
} }
} }
private readonly CaptionPresses presses = new();
private Window? Host => TopLevel.GetTopLevel(this) as Window; private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// <remarks> /// <remarks>
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
return; return;
} }
// Decided on the press, before the drag would take it: see CaptionPresses.
if (presses.IsSecond(e, this))
{
OnToggleMaximised(sender, e);
e.Handled = true;
return;
}
Host?.BeginMoveDrag(e); Host?.BeginMoveDrag(e);
} }
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
/// <remarks> /// <remarks>
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and /// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
/// the one people reach for without thinking about it. /// the one people reach for without thinking about it. The double-click comes from <see cref="OnDrag"/>
/// rather than <c>DoubleTapped</c>; see <see cref="CaptionPresses"/>.
/// </remarks> /// </remarks>
private void OnToggleMaximised(object? sender, RoutedEventArgs e) private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{ {
@@ -1190,8 +1190,24 @@
Text="{Binding Status}" IsVisible="{Binding IsConnected}" /> Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
</Grid> </Grid>
<ScrollViewer Grid.Row="2"> <!--
<ItemsControl ItemsSource="{Binding Transfers}"> Virtualised: a folder download queues a row per file, and a stack panel inside an outer scroller
laid out and drew every one of them, thousands deep. The scroller is the control's own so the panel
knows the viewport it has to fill and realises only those rows.
-->
<ItemsControl Grid.Row="2" ItemsSource="{Binding Transfers}">
<ItemsControl.Template>
<ControlTemplate>
<ScrollViewer>
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" />
</ScrollViewer>
</ControlTemplate>
</ItemsControl.Template>
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:TransferRowViewModel"> <DataTemplate x:DataType="vm:TransferRowViewModel">
<!-- <!--
@@ -1239,7 +1255,6 @@
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
</ScrollViewer>
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0" <Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" /> HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
@@ -44,15 +44,15 @@
IsEnabled="{Binding !IsBusy}" /> IsEnabled="{Binding !IsBusy}" />
<!-- <!--
Shown only when this machine has both a registered wrap and a keystore still willing to release the Shown only when this machine has both a registered wrap and a keystore still willing to release the
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never key. Absent rather than disabled, because a greyed-out "Use Touch ID" on a machine that never
had it invites the reading that something is broken — and the passphrase box beside it is not a had it invites the reading that something is broken — and the passphrase box beside it is not a
fallback, it is the ordinary way in. fallback, it is the ordinary way in.
--> -->
<Button Classes="ghost" Content="USE WINDOWS HELLO" <Button Classes="ghost" Content="{Binding DeviceUnlock.Button}"
Command="{Binding UnlockWithDeviceCommand}" Command="{Binding UnlockWithDeviceCommand}"
IsEnabled="{Binding !IsBusy}" IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanUnlockWithDevice}" IsVisible="{Binding CanUnlockWithDevice}"
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." /> ToolTip.Tip="{Binding DeviceUnlock.Tip}" />
</StackPanel> </StackPanel>
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" /> <TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
@@ -0,0 +1,383 @@
using System.Collections.Specialized;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Layout;
namespace DodoSSH.Client.App.Views;
/// <summary>
/// A wrap panel that builds only the items near the part of it on screen.
/// </summary>
/// <remarks>
/// <para>
/// The hosts board is a list per section, each laid out in rows of cards. It was a plain
/// <see cref="WrapPanel"/>, which builds, measures and draws every card on the board whether it is in view or
/// not — some twenty-five controls a card, so a keychain of three hundred hosts took seconds to open and to
/// redraw after a filter was cleared, and a thousand took several times that. Avalonia has no virtualising
/// wrap panel of its own; this is one, for items of a fixed width.
/// </para>
/// <para>
/// <b>Widths are the first item's; heights are per row.</b> A card's width is set by its style, so the
/// number of columns is arithmetic. Its height is not quite — a card wearing many tags grows a line — so each
/// row's height is the tallest card built in it, and a row not yet built is assumed to be as tall as the last
/// one measured. When a row turns out different the rows below it move, which is the ordinary price of
/// estimating, and is only ever seen while scrolling into rows nobody has looked at yet.
/// </para>
/// <para>
/// <b>What is on screen is read from the effective viewport</b>, which is the part of this panel the
/// scroller around the whole board shows — the section's own list never scrolls, so its own scroller has
/// nothing to say. A section scrolled out of view builds nothing at all past the one card measured for size.
/// </para>
/// <para>
/// Everything else a list does is left to the list. Containers come from its generator and are prepared by
/// it, so selection, the card template, the context menu and the band all work on the containers that exist.
/// A container holding the keyboard is never put away while it does, so arrowing past the edge of the view
/// does not drop the focus on the floor.
/// </para>
/// </remarks>
internal sealed class VirtualizingWrapPanel : VirtualizingPanel
{
/// <summary>How far past the visible part, above and below, items are built ahead of being scrolled to.</summary>
private const double Overscan = 240;
/// <summary>What a row is assumed to be before anything in the panel has been measured.</summary>
private const double FallbackRowHeight = 124;
private readonly Dictionary<int, Control> realized = [];
private readonly Dictionary<object, Stack<Control>> recycled = [];
/// <summary>The pool each container this panel made goes back to; items that are their own containers are not in it.</summary>
private readonly Dictionary<Control, object> poolOf = [];
private readonly Dictionary<int, double> rowHeights = [];
private Rect viewport;
private bool viewportKnown;
private Size itemSize;
private int columns = 1;
/// <summary>An item asked to be scrolled to, kept built until a measure has seen it in range.</summary>
private int? pinned;
public VirtualizingWrapPanel()
{
EffectiveViewportChanged += (_, e) =>
{
if (viewportKnown && e.EffectiveViewport == viewport)
{
return;
}
viewport = e.EffectiveViewport;
viewportKnown = true;
InvalidateMeasure();
};
}
/// <inheritdoc />
protected override Size MeasureOverride(Size availableSize)
{
var items = Items;
if (items.Count == 0)
{
RecycleAllExcept(static _ => false);
return default;
}
if (itemSize == default)
{
var first = RealizedOrNew(0);
first.Measure(Size.Infinity);
itemSize = first.DesiredSize;
}
var width = double.IsInfinity(availableSize.Width)
? itemSize.Width * items.Count
: availableSize.Width;
var nowColumns = Math.Max(1, (int)Math.Floor(width / Math.Max(1, itemSize.Width)));
if (nowColumns != columns)
{
columns = nowColumns;
rowHeights.Clear();
}
var tops = RowTops(items.Count);
var (firstIndex, lastIndex) = VisibleRange(tops, items.Count);
RecycleAllExcept(index =>
(index >= firstIndex && index <= lastIndex)
|| index == pinned
|| realized[index].IsKeyboardFocusWithin);
for (var index = firstIndex; index <= lastIndex; index++)
{
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
var row = index / columns;
rowHeights[row] = Math.Max(rowHeights.GetValueOrDefault(row), container.DesiredSize.Height);
}
if (pinned is { } kept && kept >= firstIndex && kept <= lastIndex)
{
pinned = null;
}
tops = RowTops(items.Count);
return new Size(Math.Min(width, columns * itemSize.Width), tops[^1]);
}
/// <inheritdoc />
protected override Size ArrangeOverride(Size finalSize)
{
var tops = RowTops(Items.Count);
foreach (var (index, container) in realized)
{
container.Arrange(ItemRect(index, tops));
}
return finalSize;
}
/// <inheritdoc />
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
{
base.OnItemsChanged(items, e);
// Every change is answered as a reset. The board swaps its lists in whole, so an incremental answer
// would be code for a case that does not happen; and indexes, row heights and the pinned item all
// describe a list that is no longer the one being shown.
RecycleAllExcept(static _ => false);
rowHeights.Clear();
pinned = null;
InvalidateMeasure();
}
/// <inheritdoc />
protected override Control? ScrollIntoView(int index)
{
if (index < 0 || index >= Items.Count)
{
return null;
}
var container = RealizedOrNew(index);
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
container.Arrange(ItemRect(index, RowTops(Items.Count)));
pinned = index;
container.BringIntoView();
return container;
}
/// <inheritdoc />
protected override Control? ContainerFromIndex(int index) => realized.GetValueOrDefault(index);
/// <inheritdoc />
protected override int IndexFromContainer(Control container)
{
foreach (var (index, held) in realized)
{
if (ReferenceEquals(held, container))
{
return index;
}
}
return -1;
}
/// <inheritdoc />
protected override IEnumerable<Control>? GetRealizedContainers() => realized.Values;
/// <inheritdoc />
/// <remarks>
/// Left and right step one card, up and down one row of them — the movements the plain wrap panel's own
/// navigation offered, so the board's arrows do what they did, with the card stepped to built on demand.
/// </remarks>
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
{
var count = Items.Count;
if (count == 0)
{
return null;
}
var current = from is Control control ? IndexFromContainer(control) : -1;
var target = direction switch
{
NavigationDirection.First => 0,
NavigationDirection.Last => count - 1,
NavigationDirection.Left or NavigationDirection.Previous => current - 1,
NavigationDirection.Right or NavigationDirection.Next => current + 1,
NavigationDirection.Up => current - columns,
NavigationDirection.Down => current + columns,
_ => -1,
};
if (current < 0 && direction is not (NavigationDirection.First or NavigationDirection.Last))
{
target = 0;
}
if (target < 0 || target >= count)
{
if (!wrap)
{
return null;
}
target = (target % count + count) % count;
}
return ScrollIntoView(target);
}
/// <summary>The top of every row, and one more entry past the last row for the total height.</summary>
private double[] RowTops(int count)
{
var rows = (count + columns - 1) / columns;
var tops = new double[rows + 1];
var estimate = itemSize.Height > 0 ? itemSize.Height : FallbackRowHeight;
for (var row = 0; row < rows; row++)
{
tops[row + 1] = tops[row] + rowHeights.GetValueOrDefault(row, estimate);
}
return tops;
}
/// <summary>The first and last item in the rows that overlap the visible part, with the overscan.</summary>
private (int First, int Last) VisibleRange(double[] tops, int count)
{
// Before the first viewport arrives, a screenful from the top: the panel has to measure to something.
var top = viewportKnown ? viewport.Top - Overscan : 0;
var bottom = viewportKnown ? viewport.Bottom + Overscan : 1200;
var rows = tops.Length - 1;
var firstRow = -1;
var lastRow = -1;
for (var row = 0; row < rows; row++)
{
if (tops[row + 1] < top)
{
continue;
}
if (tops[row] > bottom)
{
break;
}
if (firstRow < 0)
{
firstRow = row;
}
lastRow = row;
}
return firstRow < 0
? (0, -1)
: (firstRow * columns, Math.Min(count - 1, ((lastRow + 1) * columns) - 1));
}
private Rect ItemRect(int index, double[] tops)
{
var row = index / columns;
var column = index % columns;
var height = row + 1 < tops.Length ? tops[row + 1] - tops[row] : itemSize.Height;
return new Rect(column * itemSize.Width, tops[Math.Min(row, tops.Length - 1)], itemSize.Width, height);
}
/// <summary>The container for an item, built or brought back from the pool if it is not already up.</summary>
private Control RealizedOrNew(int index)
{
if (realized.TryGetValue(index, out var existing))
{
return existing;
}
var generator = ItemContainerGenerator!;
var item = Items[index];
Control container;
if (generator.NeedsContainer(item, index, out var recycleKey))
{
var key = recycleKey ?? typeof(object);
if (recycled.TryGetValue(key, out var pool) && pool.TryPop(out var reused))
{
container = reused;
container.IsVisible = true;
}
else
{
container = generator.CreateContainer(item, index, recycleKey);
poolOf[container] = key;
AddInternalChild(container);
}
generator.PrepareItemContainer(container, item, index);
realized[index] = container;
generator.ItemContainerPrepared(container, item, index);
}
else
{
// An item that is its own container: built by whoever made the item, and never pooled.
container = (Control)item!;
AddInternalChild(container);
realized[index] = container;
}
return container;
}
/// <summary>Puts away every built container the predicate does not keep.</summary>
private void RecycleAllExcept(Func<int, bool> keep)
{
foreach (var index in realized.Keys.ToList())
{
if (keep(index))
{
continue;
}
var container = realized[index];
realized.Remove(index);
if (poolOf.TryGetValue(container, out var key))
{
ItemContainerGenerator!.ClearItemContainer(container);
container.IsVisible = false;
if (!recycled.TryGetValue(key, out var pool))
{
recycled[key] = pool = new Stack<Control>();
}
pool.Push(container);
}
else
{
RemoveInternalChild(container);
}
}
}
}
+87 -1
View File
@@ -45,6 +45,13 @@ public sealed record HostSecret : IVaultSecret
/// </remarks> /// </remarks>
public const int MaxPinnedPaths = 32; public const int MaxPinnedPaths = 32;
/// <summary>The most port forwards a host may carry.</summary>
/// <remarks>
/// Each one is a listener opened with every terminal to the host, so the bound is on what one connect
/// may open as much as on the payload.
/// </remarks>
public const int MaxPortForwards = 16;
/// <summary>Display name. The only name this host has anywhere.</summary> /// <summary>Display name. The only name this host has anywhere.</summary>
public required string Label { get; init; } public required string Label { get; init; }
@@ -223,6 +230,15 @@ public sealed record HostSecret : IVaultSecret
/// </remarks> /// </remarks>
public PinnedPathList PinnedPaths { get; init; } = PinnedPathList.Empty; public PinnedPathList PinnedPaths { get; init; } = PinnedPathList.Empty;
/// <summary>
/// The port forwards started with every terminal opened to this host.
/// </summary>
/// <remarks>
/// Here rather than in an item of their own; see <see cref="PortForwardRule"/>. Merged per forward, so two
/// machines each adding one both keep theirs, and two editing the same one is a recorded conflict.
/// </remarks>
public PortForwardList PortForwards { get; init; } = PortForwardList.Empty;
/// <summary> /// <summary>
/// The group this host is filed under, or null for none. /// The group this host is filed under, or null for none.
/// </summary> /// </summary>
@@ -281,6 +297,34 @@ public sealed record HostSecret : IVaultSecret
/// </remarks> /// </remarks>
public bool RelayEnabled { get; init; } public bool RelayEnabled { get; init; }
/// <summary>
/// How this host is reached through Spelunk, or null for a host that is dialled.
/// </summary>
/// <remarks>
/// <para>
/// A host carrying one is never dialled directly and never falls back to it, for the reason the relay
/// does not: the user chose this route because the direct one does not exist or does not work, and a
/// connection that quietly went another way would be the box saying one thing and the connection doing
/// another. See <see cref="SpelunkRoute"/> for what the route is.
/// </para>
/// <para>
/// <b><see cref="Hostname"/> and <see cref="Port"/> still matter, and not for dialling.</b> The host key
/// is pinned under them, as it is for every host, so they have to name this machine stably — a host made
/// from a Spelunk link takes the server's address, or a name made from its id when Spelunk gave none.
/// The port is also what the agent is asked to connect to on the server's own loopback.
/// </para>
/// <para>
/// <b>Mutually exclusive with <see cref="RelayEnabled"/>.</b> Two routes to one host is the same failure
/// as two bindings: the editor, the connect path and the user would each be free to guess which one is
/// meant. <see cref="TryValidate"/> refuses the pair.
/// </para>
/// </remarks>
public SpelunkRoute? Spelunk { get; init; }
/// <summary>Whether Spelunk signs the user in to this host, so it needs no login of its own.</summary>
/// <remarks>See <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</remarks>
public bool SignsInThroughSpelunk => Spelunk?.SignInThroughSpelunk is true;
/// <summary> /// <summary>
/// Checks the fields that must hold before this can be stored. /// Checks the fields that must hold before this can be stored.
/// </summary> /// </summary>
@@ -314,7 +358,49 @@ public sealed record HostSecret : IVaultSecret
return false; return false;
} }
return PinnedPathsAreStorable(out reason) && ReferencesAreStorable(out reason); return PinnedPathsAreStorable(out reason)
&& PortForwardsAreStorable(out reason)
&& ReferencesAreStorable(out reason)
&& RouteIsStorable(out reason);
}
/// <summary>Checks the forwards saved on this host, each on its own terms and the list as a whole.</summary>
private bool PortForwardsAreStorable([NotNullWhen(false)] out string? reason)
{
if (PortForwards.Count > MaxPortForwards)
{
reason = $"A host cannot carry more than {MaxPortForwards} port forwards.";
return false;
}
foreach (var forward in PortForwards)
{
if (!forward.TryValidate(out reason))
{
return false;
}
}
reason = null;
return true;
}
/// <summary>Checks the Spelunk route, if there is one, and that it is the only route.</summary>
private bool RouteIsStorable([NotNullWhen(false)] out string? reason)
{
if (Spelunk is null)
{
reason = null;
return true;
}
if (RelayEnabled)
{
reason = "A host is reached through the server relay or through Spelunk, not both.";
return false;
}
return Spelunk.TryValidate(out reason);
} }
/// <summary> /// <summary>
+194 -1
View File
@@ -112,12 +112,51 @@ public static class HostSecretCodec
/// </remarks> /// </remarks>
public const int PinnedPathsSchemaVersion = 7; public const int PinnedPathsSchemaVersion = 7;
/// <summary>The version that introduced <see cref="HostSecret.PortForwards"/>.</summary>
/// <remarks>
/// Its own version for the reason <see cref="PinnedPathsSchemaVersion"/> gives: a host can carry
/// forwards without pinning anything, and should stay editable on a build that knows 7 until it does.
/// </remarks>
public const int PortForwardsSchemaVersion = 8;
/// <summary>The version that introduced <see cref="HostSecret.Spelunk"/>.</summary>
/// <remarks>
/// <para>
/// Its own version, by the rule this file follows, and it matters more here than for a tag. An older
/// build that decoded a Spelunk host as editable would drop the route on the next save — and the host
/// would then be dialled directly, at an address that exists only so its key has a name to be pinned
/// under. Written at this version, the older build shows the host, refuses to edit it, and says to update.
/// </para>
/// <para>
/// Additive, like every version but <see cref="PortInheritSchemaVersion"/>: the route is one more
/// property, and a build that does not know it skips it and still reads a valid host. It follows
/// <see cref="PortForwardsSchemaVersion"/>, whose forwards reached main first.
/// </para>
/// </remarks>
public const int SpelunkSchemaVersion = 9;
/// <summary>The version that introduced <see cref="SpelunkRoute.SignInThroughSpelunk"/>.</summary>
/// <remarks>
/// <para>
/// Its own version, and nested in the existing <c>spelunk</c> object rather than beside it, because it
/// is part of that one decision. A build that knows 9 skips the unknown member and reads a valid host —
/// a Spelunk host with no login of its own — and it must not save that back: the flag would be dropped
/// and the host would start asking for a password it never had. Written at this version, that build
/// shows the host, refuses to edit it, and says to update.
/// </para>
/// <para>
/// Only a route that signs in is written here. Every other Spelunk host stays at
/// <see cref="SpelunkSchemaVersion"/>, byte for byte what it was.
/// </para>
/// </remarks>
public const int SpelunkSignInSchemaVersion = 10;
/// <summary>The highest schema version this build can write.</summary> /// <summary>The highest schema version this build can write.</summary>
/// <remarks> /// <remarks>
/// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a /// Names the highest constant above, which <see cref="SchemaVersionFor"/> assumes when it takes a
/// maximum. A new field added below this line has to be named here too. /// maximum. A new field added below this line has to be named here too.
/// </remarks> /// </remarks>
public const int CurrentSchemaVersion = PinnedPathsSchemaVersion; public const int CurrentSchemaVersion = SpelunkSignInSchemaVersion;
/// <summary>Serialises a host to the bytes that get sealed.</summary> /// <summary>Serialises a host to the bytes that get sealed.</summary>
/// <exception cref="ArgumentException">The host is not valid for storage.</exception> /// <exception cref="ArgumentException">The host is not valid for storage.</exception>
@@ -163,6 +202,14 @@ public static class HostSecretCodec
// Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly // Last, and null for the same reason TagIds is: a host that pins nothing must encode exactly
// as it did before this field existed. // as it did before this field existed.
PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths], PinnedPaths = host.PinnedPaths.Count == 0 ? null : [.. host.PinnedPaths],
// Last, and null when empty, for the reason both fields above give.
PortForwards = host.PortForwards.Count == 0
? null
: [.. host.PortForwards.Select(PortForwardDocument.From)],
// Last of all, and absent for a host that is dialled, so every host without a route encodes
// exactly as it did before routes existed.
Spelunk = host.Spelunk is { } route ? SpelunkPayloadDocument.From(route) : null,
}; };
return JsonSerializer.SerializeToUtf8Bytes( return JsonSerializer.SerializeToUtf8Bytes(
@@ -238,6 +285,21 @@ public static class HostSecretCodec
version = Math.Max(version, PinnedPathsSchemaVersion); version = Math.Max(version, PinnedPathsSchemaVersion);
} }
if (host.PortForwards.Count > 0)
{
version = Math.Max(version, PortForwardsSchemaVersion);
}
if (host.Spelunk is not null)
{
version = Math.Max(version, SpelunkSchemaVersion);
}
if (host.SignsInThroughSpelunk)
{
version = Math.Max(version, SpelunkSignInSchemaVersion);
}
return version; return version;
} }
@@ -295,6 +357,11 @@ public static class HostSecretCodec
return false; return false;
} }
if (!TryBuildForwards(parsed.PortForwards, out var forwards))
{
return false;
}
var candidate = new HostSecret var candidate = new HostSecret
{ {
Label = parsed.Label ?? string.Empty, Label = parsed.Label ?? string.Empty,
@@ -316,6 +383,9 @@ public static class HostSecretCodec
TagIds = TagSet.Create(parsed.TagIds ?? []), TagIds = TagSet.Create(parsed.TagIds ?? []),
PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []), PinnedPaths = PinnedPathList.Create(parsed.PinnedPaths ?? []),
PortForwards = forwards,
Spelunk = RouteFrom(parsed.Spelunk),
}; };
if (!candidate.TryValidate(out _)) if (!candidate.TryValidate(out _))
@@ -326,6 +396,65 @@ public static class HostSecretCodec
host = candidate; host = candidate;
return true; return true;
} }
/// <remarks>
/// A missing id, kind or port is a refusal rather than a default. Each would be invented here — a fresh
/// id the other machines do not have, a <c>Local</c> nobody chose — and an invented forward is one the
/// next save writes back as though somebody had asked for it.
/// </remarks>
private static bool TryBuildForwards(
PortForwardDocument?[]? parsed,
[NotNullWhen(true)] out PortForwardList? forwards)
{
forwards = PortForwardList.Empty;
if (parsed is null)
{
return true;
}
var rules = new List<PortForwardRule>(parsed.Length);
foreach (var entry in parsed)
{
if (entry?.Id is not { } id || entry.Kind is not { } kind || entry.BindPort is not { } bindPort)
{
return false;
}
rules.Add(new PortForwardRule
{
Id = id,
Kind = (PortForwardKind)kind,
BindPort = bindPort,
TargetHost = entry.TargetHost,
TargetPort = entry.TargetPort,
});
}
forwards = PortForwardList.Create(rules);
return true;
}
/// <summary>The route as stored, or null when there is none.</summary>
/// <remarks>
/// Missing parts come through as empty values rather than making the route vanish, so that
/// <see cref="HostSecret.TryValidate"/> refuses the host. Reading a half-written route as "no route"
/// would turn a Spelunk host into one that is dialled directly, which is the one reading of a damaged
/// payload that does something the user never asked for.
/// </remarks>
private static SpelunkRoute? RouteFrom(SpelunkPayloadDocument? parsed) =>
parsed is null
? null
: new SpelunkRoute
{
ApiBase = parsed.Api ?? string.Empty,
ServerId = parsed.ServerId ?? Guid.Empty,
ApiKeyCredentialId = parsed.ApiKeyCredentialId ?? Guid.Empty,
// False folds to the default, as AsksForPassword's does: one state, one spelling.
SignInThroughSpelunk = parsed.SignIn is true,
};
} }
/// <summary> /// <summary>
@@ -396,6 +525,70 @@ internal sealed class HostPayloadDocument
/// newer of the two and follows the same rule. /// newer of the two and follows the same rule.
/// </remarks> /// </remarks>
public string[]? PinnedPaths { get; set; } public string[]? PinnedPaths { get; set; }
/// <remarks>Last, and null rather than <c>[]</c>, for the reasons <see cref="TagIds"/> gives.</remarks>
public PortForwardDocument?[]? PortForwards { get; set; }
/// <remarks>
/// Last, and null for a host that is dialled, for the reason <see cref="TagIds"/> gives. One nested object
/// rather than three loose properties, because the three are one decision — a server id without its
/// Spelunk means nothing — and an object makes "all or none" the shape of the JSON.
/// </remarks>
public SpelunkPayloadDocument? Spelunk { get; set; }
}
/// <summary>The serialised shape of a <see cref="SpelunkRoute"/>. Nullable because it models untrusted input.</summary>
internal sealed class SpelunkPayloadDocument
{
public string? Api { get; set; }
public Guid? ServerId { get; set; }
public Guid? ApiKeyCredentialId { get; set; }
/// <remarks>
/// Last, and null unless true, so a route that does not sign in keeps the bytes it had at version 9.
/// See <see cref="HostSecretCodec.SpelunkSignInSchemaVersion"/>.
/// </remarks>
public bool? SignIn { get; set; }
public static SpelunkPayloadDocument From(SpelunkRoute route) => new()
{
Api = route.ApiBase,
ServerId = route.ServerId,
ApiKeyCredentialId = route.ApiKeyCredentialId,
// Null rather than false, so a route that does not sign in encodes exactly as it did before the
// field existed — the rule AsksForPassword follows.
SignIn = route.SignInThroughSpelunk ? true : null,
};
}
/// <summary>One forward's serialised shape. Nullable throughout, because it models untrusted input.</summary>
/// <remarks>
/// The kind is written as its number rather than its name, so renaming a member of
/// <see cref="PortForwardKind"/> can never change what is on the wire.
/// </remarks>
internal sealed class PortForwardDocument
{
public Guid? Id { get; set; }
public int? Kind { get; set; }
public int? BindPort { get; set; }
public string? TargetHost { get; set; }
public int? TargetPort { get; set; }
public static PortForwardDocument From(PortForwardRule rule) => new()
{
Id = rule.Id,
Kind = (int)rule.Kind,
BindPort = rule.BindPort,
TargetHost = rule.TargetHost,
TargetPort = rule.TargetPort,
};
} }
[JsonSourceGenerationOptions( [JsonSourceGenerationOptions(
@@ -99,6 +99,8 @@ public static class HostSecretMerge
TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts), TagIds = MergeTags(ancestor.TagIds, local.TagIds, remote.TagIds, conflicts),
PinnedPaths = MergePinnedPaths( PinnedPaths = MergePinnedPaths(
ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts), ancestor.PinnedPaths, local.PinnedPaths, remote.PinnedPaths, conflicts),
PortForwards = MergePortForwards(
ancestor.PortForwards, local.PortForwards, remote.PortForwards, conflicts),
RelayEnabled = Field( RelayEnabled = Field(
nameof(HostSecret.RelayEnabled), nameof(HostSecret.RelayEnabled),
ancestor.RelayEnabled, ancestor.RelayEnabled,
@@ -166,6 +168,18 @@ public static class HostSecretMerge
remote.AsksForPassword, remote.AsksForPassword,
conflicts, conflicts,
static asked => asked is true ? "a typed password" : "the group's binding"), static asked => asked is true ? "a typed password" : "the group's binding"),
// Here rather than beside RelayEnabled, because it is a reference too — to a Spelunk server and to
// the credential holding its key — and it merges as one value for the reason the jump chain does:
// an API address from one side and a server id from the other would be a route neither user
// chose, to a server that may not exist on that Spelunk.
Spelunk = Field(
nameof(HostSecret.Spelunk),
ancestor.Spelunk,
local.Spelunk,
remote.Spelunk,
conflicts,
static route => route is null ? "dialled directly" : $"server {route.ServerId} on {route.ApiBase}"),
}; };
private static string Text( private static string Text(
@@ -342,6 +356,46 @@ public static class HostSecretMerge
return PinnedPathList.Create(merge.Merged.Keys); return PinnedPathList.Create(merge.Merged.Keys);
} }
/// <summary>
/// Merges the forwards per forward, so two people each adding one both keep theirs.
/// </summary>
/// <remarks>
/// <para>
/// Keyed by id and ordered the way <see cref="MergePinnedPaths"/> is: existing forwards, then this
/// machine's additions, then the server's.
/// </para>
/// <para>
/// <b>Unlike a tag or a pinned path, a conflict is reachable here.</b> The value under a key is the whole
/// rule, so two machines changing one forward differently is a genuine clash. The server's version wins,
/// as every scalar's does, and the other is recorded in OpenSSH's notation so the user can read what was
/// lost. An edit on one side beats a removal on the other, which is the map merge's standing rule.
/// </para>
/// </remarks>
private static PortForwardList MergePortForwards(
PortForwardList ancestor,
PortForwardList local,
PortForwardList remote,
List<HostFieldConflict> conflicts)
{
var merge = ThreeWayMerge.Map(
ancestor.ToIdMap(),
local.ToIdMap(),
remote.ToIdMap(),
EqualityComparer<Guid>.Default);
foreach (var conflict in merge.Conflicts)
{
conflicts.Add(new HostFieldConflict(
$"{nameof(HostSecret.PortForwards)}[{conflict.Key}]",
conflict.DiscardedSide,
conflict.Kept?.Notation ?? "removed",
conflict.Discarded?.Notation ?? "removed",
conflict.DiscardedWasRemoval));
}
return PortForwardList.Create(merge.Merged.Values);
}
private static string FormatChain(JumpChain chain) => private static string FormatChain(JumpChain chain) =>
chain.Count == 0 ? "(none)" : string.Join(" → ", chain); chain.Count == 0 ? "(none)" : string.Join(" → ", chain);
} }
Loaded 100 of 250 files, more files were not shown because too many files have changed in this diff. Show more