Public Access
Merge branch 'main' into the desktop updater, and give way on two numbers
Main landed a realtime push feature while this branch was building the updater, and the two collided in three places. Every one of them resolves the same way: main got there first, so this branch moves. **Two ADRs were both numbered 0012.** Main's is realtime push; this one is now [ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call this a conflict — the filenames differ — so it would have merged quietly and left the directory with two 0012s and every cross-reference ambiguous. Renumbered here along with the nine places that point at it. **Two manual-check phases were both numbered 15**, and that one git did catch. Main's "Changes that arrive without a timer" keeps 15; installing and updating the desktop client becomes Phase 16, with its checks and every reference to them renumbered. The file's own rule is that a number is for life, which is exactly why the one that had not been pushed is the one that gives way. **The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and `EnforceCodeStyleInBuild` is on, so it failed the build on three lines of App.axaml.cs whose only change in this branch was an ADR number in a comment. Forty-six files normalised back to LF; the release script keeps CRLF, which is what `.gitattributes` and `.editorconfig` both already say for a PowerShell file. Nothing else conflicted. The updater does not touch the sync loop or the event stream, and the one file both sides edited heavily — MainWindowViewModel — merged without a hunk in common. Verified after merging: the solution restores locked and builds clean, and 304 shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The first two counts are higher than before the merge because main's own tests came with it and pass alongside these.
This commit is contained in:
@@ -118,7 +118,7 @@ signing certificate, so Windows SmartScreen shows *"Windows protected your PC"*
|
||||
installer; **More info → Run anyway** gets past it. That is the honest state of things rather than something
|
||||
to click through blindly — it is a statement that Microsoft has not seen this file before, and it will stop
|
||||
appearing when the project buys a certificate.
|
||||
[ADR 0012](docs/adr/0012-desktop-distribution-and-updates.md) says what that costs and when it happens. The
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md) says what that costs and when it happens. The
|
||||
warning is once per person: updates from inside the application do not raise it.
|
||||
|
||||
**Updates.** The client checks the project's release page every six hours, downloads a newer build in the
|
||||
@@ -140,7 +140,7 @@ you want the machine to genuinely forget everything — an uninstall is not a si
|
||||
this machine's device key from your account.
|
||||
|
||||
Cutting a release is `scripts/release-windows.ps1`, run by a person on a Windows machine. Deliberately not a
|
||||
CI job; ADR 0012 decision 3 explains why, and it is not only that the runners are Linux.
|
||||
CI job; ADR 0013 decision 3 explains why, and it is not only that the runners are Linux.
|
||||
|
||||
## Running it
|
||||
|
||||
@@ -377,14 +377,75 @@ Six limits, stated rather than discovered:
|
||||
into it that is not there.
|
||||
|
||||
Items are filed into one vault at a time, and which one is asked at the moment the item is made. **A host's
|
||||
editor has its own picker**, beside the name, because that is the decision that cannot be undone: the two
|
||||
vaults are encrypted under different keys, so moving an item afterwards means deleting it and typing it
|
||||
again — and the picker is therefore absent when you edit an existing host rather than present and refusing.
|
||||
Keys, passwords and buckets take theirs from a standing "new items go to" picker on the Keychain screen.
|
||||
editor has its own picker**, beside the name, because it is the decision on that form that decides who can
|
||||
read the host. It is absent when you edit an existing one rather than present and refusing, and that is not
|
||||
because the host is stuck: **a host can be moved to another vault** — "Move to another vault…" in the detail
|
||||
pane's menu on the desktop, MOVE beside EDIT on the phone. It is a separate act because it is not a save.
|
||||
The two vaults are encrypted under different keys, so a move is a re-seal into one and a tombstone in the
|
||||
other; the host gets a new id, and **its group and its tags stay behind**, because both are items of the
|
||||
vault it is leaving. A picker inside the form would do all of that as a side effect of correcting a port.
|
||||
What a move cannot do is reach a machine that has already synced the host, which is the same limit
|
||||
everything else about revocation has. Keys, passwords and buckets take theirs from a standing "new items go
|
||||
to" picker on the Keychain screen and cannot be moved yet.
|
||||
|
||||
**A group can be moved too, and it takes its contents with it** — "Move to another vault…" on the group
|
||||
card's right-click menu, beside Open, Edit and Delete, which is the whole of what can be done to a group on
|
||||
the desktop. That is the desktop only, because the phone draws groups as headings in the host list and has
|
||||
never had a way to delete or move one. It is the same re-seal
|
||||
and tombstone underneath, applied to every item involved: the group, the groups nested inside it, and every
|
||||
host filed under any of them, each taking a new id in the destination. Moving less than that was never
|
||||
coherent — the machines and the child groups are items of the vault the group is leaving, so a group that
|
||||
travelled alone would leave half a shelf behind. What stays is the group it was itself nested under, which
|
||||
belongs to the old vault, so it arrives at the top level; the hosts' tags stay for the same reason. Keys and
|
||||
passwords are kept, because those genuinely resolve across vaults, and the sentence afterwards names any
|
||||
that are now outside the destination.
|
||||
|
||||
**Deleting a group asks what should become of the hosts under it.** The default answer keeps them: the
|
||||
reference is cleared and they move to UNGROUPED. Ticking the box deletes them with it. Both answers are a
|
||||
change — the deletion used to leave the hosts holding an id that no longer resolved, which looked the same
|
||||
and cost nothing, and stopped being the right shape once the deletion could take them with it. A group with
|
||||
nothing filed under it is not asked. Whichever answer is given, the groups nested inside take the deleted
|
||||
group's place in the tree rather than being orphaned to the top level.
|
||||
Both default to your personal vault and neither moves on its own, because an item put in a shared vault is
|
||||
visible to everybody holding that vault's key. Choosing a vault in the host editor also decides which groups
|
||||
it can be filed under: a group is an item like any other and lives in exactly one vault.
|
||||
|
||||
### Changes that do not wait
|
||||
|
||||
A client holds a WebSocket open to the server — `GET /api/v1/events`, subprotocol
|
||||
`dodossh.events.v1` — and the server sends a line down it whenever something you can read has moved. The
|
||||
client's answer is the same delta pull it would have run on its timer, only now rather than in up to a
|
||||
minute. Two things you can see: an edit somebody else makes appears while you are looking at the list, and
|
||||
a vault shared with you turns up as soon as they share it.
|
||||
|
||||
**What is on that socket is a notice, not your data.** A frame says which vault changed and how far its
|
||||
change log has got, and nothing else: no item, no ciphertext, not even which item it was. That is the
|
||||
decision the rest of this rests on, and it is deliberate twice over — the server has nothing else it
|
||||
*could* send, and keeping it that way means there is still exactly one path that applies a change to your
|
||||
keychain, so the socket can be wrong or absent without anything being applied incorrectly.
|
||||
|
||||
**Polling is still there and is still what guarantees a pass.** The minute timer is unchanged. A network
|
||||
that eats WebSockets, a server with `Events:Enabled` off, an older server, a proxy that will not upgrade, a
|
||||
notice dropped because your machine was too slow to read it — every one of those leaves you with exactly
|
||||
what this product did before the socket existed. Nothing is reachable only this way, and nothing is
|
||||
supposed to become so.
|
||||
|
||||
Three limits are worth knowing rather than discovering:
|
||||
|
||||
- **One node.** Fan-out is in-process, so a deployment running more than one API replica only pushes for
|
||||
writes that its own replica handled. The rest arrive on the timer. The seam for a PostgreSQL
|
||||
`LISTEN`/`NOTIFY` backplane is in place and is not implemented, because an untested backplane would be
|
||||
worse than a documented gap.
|
||||
- **The socket does not outlive your access token.** It is closed at the token's expiry and the client
|
||||
reconnects with a fresh one, which is a gap you will not see. That, plus re-reading your vault list every
|
||||
few minutes, is what bounds how long a withdrawn grant can keep producing notices — and what it bounds is
|
||||
*metadata*, because reading a vault needs a key the server has never held.
|
||||
- **You are told about your own writes.** Your client pushed, so it has already pulled; the extra pass finds
|
||||
nothing. Notices are coalesced over a quarter of a second so that a burst is one pass rather than a dozen.
|
||||
|
||||
The reasoning, including why this is a WebSocket rather than server-sent events and where a shared terminal
|
||||
session will attach to it, is in [ADR 0012](docs/adr/0012-realtime-push.md).
|
||||
|
||||
### The Android head
|
||||
|
||||
`src/DodoSSH.Client.Android` is a phone-first head that shares every view model with the desktop one — the
|
||||
@@ -632,6 +693,16 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
than to a shell that never opens. Given a walk that had to be cycle-safe anyway, refusing to nest bought
|
||||
nothing.
|
||||
|
||||
Membership living on the host has one further consequence, and it took two goes to settle. Deleting a
|
||||
group could not clear it without rewriting every host under the heading, so at first it did not: the hosts
|
||||
kept an id that resolved to nothing and turned up under UNGROUPED, which reads identically and costs no
|
||||
writes. That held until the deletion had to be able to take the hosts *with* it — a group is sometimes a
|
||||
heading being tidied away and sometimes a project that has been decommissioned, and nothing in the code can
|
||||
tell which. Once a deletion knows which hosts it means, leaving them naming something that has gone is a
|
||||
state kept for no reason, so both answers now write: N deletions, or N hosts with the reference cleared.
|
||||
The dangling case still has to be survived everywhere it is read, because a group deleted on *another*
|
||||
machine arrives exactly that way.
|
||||
|
||||
Inserting a snippet types it at the prompt and stops. Pressing Enter is a per-snippet decision, off by
|
||||
default, and the reason is worth stating: a terminal is one input stream with no notion of being at a
|
||||
prompt — the remote may be in an editor, or at a password prompt with the echo off — so this client cannot
|
||||
@@ -658,6 +729,20 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
directories, an interrupted **upload** starts again rather than resuming (an object cannot be written from
|
||||
the middle), and a rename is a copy and a delete rather than one atomic operation. Downloads do resume — a
|
||||
ranged GET is part of the protocol, which is the one place a bucket beats SFTP.
|
||||
|
||||
*Realtime done:* a WebSocket the client holds open, over which the server says which vault has moved so a
|
||||
pull happens now rather than within the minute. What crosses it is a notice and never an item, which is
|
||||
what keeps one code path applying changes and makes a dropped socket cost latency and nothing else — the
|
||||
timer is unchanged and is still the guarantee. Two limits are stated rather than implied: fan-out is
|
||||
in-process, so a multi-replica deployment falls back to the timer for writes another replica handled, and
|
||||
a socket is closed at its access token's expiry rather than outliving the credential that authorised it.
|
||||
See [Changes that do not wait](#changes-that-do-not-wait) and
|
||||
[ADR 0012](docs/adr/0012-realtime-push.md).
|
||||
|
||||
It is also the transport a **shared terminal session** will use — one person's shell, watched or driven by
|
||||
somebody else. Nothing of that exists yet, and ADR 0012 records the one decision made early so it need not
|
||||
be renegotiated: session data will be binary frames on this same socket, because base64 in a JSON envelope
|
||||
is the wrong shape for the one payload here that is continuous rather than occasional.
|
||||
- **M3 — shared vaults**, sharing, ACLs. *Done.* Membership with roles, a public-key directory, the
|
||||
append-only key log served for clients to verify against, shared vaults, and vault key grants
|
||||
wrapped by a client and stored opaquely by the server. `VaultAccessService` now resolves team
|
||||
@@ -681,10 +766,12 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
the rotation is re-sealed as it is pushed, so nothing reaches the server under a superseded key at all.
|
||||
See [ADR 0010](docs/adr/0010-vault-key-rotation.md).
|
||||
|
||||
**A vault shared with you arrives on the next synchronisation pass**, within the minute, with no sign-in
|
||||
and nothing to press. There is no push channel, so each pass asks the server which vaults this account can
|
||||
reach before syncing the ones it already knows — which is also how a vault that has been deleted, or one
|
||||
whose grant was withdrawn, stops being listed.
|
||||
**A vault shared with you arrives at once**, with no sign-in and nothing to press. Each pass asks the
|
||||
server which vaults this account can reach before syncing the ones it already knows — which is also how a
|
||||
vault that has been deleted, or one whose grant was withdrawn, stops being listed — and the server now
|
||||
says so the moment somebody wraps a key to you rather than leaving it for the next pass. Without a
|
||||
reachable socket that becomes "within the minute", which is what it always was; see
|
||||
[Changes that do not wait](#changes-that-do-not-wait).
|
||||
|
||||
**Ownership transfer is here, and it is one write rather than two.** The member you name becomes owner
|
||||
and you become an admin, in a single transaction — because ownership is sole, so promoting first leaves
|
||||
@@ -708,7 +795,7 @@ keychain plus a terminal — and the spike that gates all of it.
|
||||
the first time the API has reported a true `serverVersion`. Releases are cut by a person rather than by
|
||||
CI: the token that writes a release is, for an updater that trusts its feed, the same capability as the
|
||||
signing key, which [ADR 0011](docs/adr/0011-android-distribution.md) rule 1 keeps off runners. See
|
||||
[ADR 0012](docs/adr/0012-desktop-distribution-and-updates.md), and
|
||||
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md), and
|
||||
[Installing on Windows](#installing-on-windows) for what a user sees.
|
||||
|
||||
Still to do here: signing (the first release is unsigned, and the trigger for buying a certificate is the
|
||||
|
||||
Reference in New Issue
Block a user