Commit Graph
12 Commits
Author SHA1 Message Date
jaap-jan 6728a0a597 Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
2026-08-04 17:04:41 +02:00
jaap-janandClaude Opus 5 8a568117df Give the API an image, and unbreak the restore that had to run first
registry-docker.dodotech.cloud/dodotech/dodossh-api, built and pushed by a third ci job
that needs the first. Gating on the tests costs a few minutes on every main commit and buys
the only thing worth having here: an image is not an artefact somebody inspects before
using it, so a red commit must not be able to produce one. Pull requests build the image
and stop, which is where a broken Dockerfile should be found.

Tags are :sha-<short> on every build, :main on main, and for a v* tag :1.2.3, :1.2 and
:latest — the last two only when the version has no prerelease suffix, since v1.3.0-rc1
sorts above v1.2.9 and would otherwise walk :latest onto somebody's server. Only sha- is
immutable, and it is the one to pin a deployment to.

No docker/* actions. The build is single-architecture, so it needs the daemon this runner
already has for the Testcontainers suites and nothing else — no buildx, no QEMU, and no
third-party action whose SHA has to be audited and re-pinned. Step outputs and secrets
reach the shell through env rather than ${{ }} interpolation, because a git tag may contain
a semicolon and interpolation is textual substitution performed before the shell parses the
line.

The image is chiseled: no shell, no package manager, uid 1654. Affordable because
Directory.Build.props already sets InvariantGlobalization, so the ICU and tzdata a normal
base carries are exactly what this product decided not to use. The cost is stated in the
Dockerfile rather than hidden — there is no HEALTHCHECK, because there is nothing to run
one with, and /healthz/ready is anonymous precisely so the orchestrator can ask instead.
Nothing migrates the schema from inside the container either; readiness fails while a
migration is pending and names it, which is the design.

And the restore that all of this depends on did not work. 7a3a521 committed lock files
carrying a net10.0/android-arm64 section into fourteen projects — written there by the
Android head's -p:RuntimeIdentifier=android-arm64 packaging build, which restores the
shared projects with a RID and updates their lock files as a side effect. Any restore
without that RID then fails NU1004 in locked mode, which is every other build there is:
`dotnet restore DodoSSH.slnx --locked-mode` has been failing for eleven projects on a clean
checkout of main since that commit. The sections are removed here and nothing else changed
— deletions only, ILLink.Tasks stays at 10.0.10.

Verified: the solution restores in locked mode, the image builds, and it runs. /healthz/live
answers 200 and /healthz/ready answers 503 naming the database it cannot reach, from a
67 MB image as uid 1654, configured entirely through DODOSSH_-prefixed variables.

The Android head's own lock file still carries the RID and is untouched, because that job
restores it separately and is outside DodoSSH.slnx. Whether its packaging step re-dirties
these fourteen on every CI run is worth a look; it is the same mechanism.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 13:40:12 +02:00
jaap-jan 7a3a521c59 Give the phone the rest of its screens, and a way in
ci / build and test (push) Failing after 2s
ci / android head (push) Failing after 1s
All seven screens of the design, plus the two it does not draw because it starts at an
enrolled phone: naming a server, and choosing a passphrase.

The five states docs/android-port.md worried about losing at 360dp are all here and none
of them softened. The changed-key refusal is a full-screen panel rather than a bottom
sheet, because a sheet is swipe-to-dismiss by convention and that screen must have no way
forward. The recovery code raises FLAG_SECURE for its own state and lowers it afterwards,
so the sentence about screenshots is true rather than decorative. The delete
confirmations keep their counts and replace the row in place.

Signing in works, and the seam it needed is worth more than the implementation:
IAuthorizationCallback now sits between OidcClient and the loopback listener, so the two
heads differ in where the response arrives and in nothing else. PKCE, the state check,
discovery, the token exchange and the key binding stay one implementation — a second OIDC
client would be a second place for a security bug to live. The phone registers a
private-use scheme with the system rather than binding a loopback port, which on a shared
device any other app can do first.

The accessory key row needed TerminalWorkspace.SendInputAsync: ordinary typing goes from
the renderer straight down the socket, and there was no way in for the keys a software
keyboard does not have. Ctrl latches, because one thumb cannot chord, and the latch is
drawn — a modifier that is on and does not look on is how somebody sends ^L to a database
prompt believing they typed an l.

597 client tests green, including two new ones for the input path and one for the
terminal surface command. Nothing has run on a device.
2026-07-31 21:43:11 +02:00
jaap-jan 5cbda59a34 Merge branch 'main' into claude/host-management-ui-plan-7f20ab
Seven files needed a hand. Most were two branches adding something in the same
place, but three were one branch changing what the other had moved or renamed,
and those are the ones worth reading.

The shell keeps both new fields and both constructor lines: the connection
recorder this branch built and the teams view model main did. Where main put a
teams load inside OnScreenChanged, it now sits beside the logs refresh rather
than inside RaiseSurfaceState — this branch extracted that notification block
and it is called from two properties, so a screen-specific side effect in there
would fire on every terminal switch as well.

Main gave four row types a vault id and a vault name, and this branch had moved
one of them — KnownHostRowViewModel — into its own file when the pinned keys
became a screen. Git resolved that as "deleted here, modified there" and took
the delete, which compiles as long as nobody looks: the moved copy still had
the two-argument constructor and the call site had grown to four. Carried over
by hand, along with the ordering the pins list now does on them.

The status line's quiet rule was the subtle one. Main extracted it into
IsWorthReporting; this branch had changed the same condition to read item
counts rather than raw ones, because every user action queues a log entry a
moment later and this machine reads its own entries back on the next pull. Take
main's structure and the merge builds, passes, and silently restores a bug this
branch existed partly to fix — every save's message overwritten a second after
it appears. The method now reads PulledItems and PushedItems, with the reason
in its remarks.

Two conflicts were prose that had gone stale rather than code. The keychain
screen's comment said team vaults are refused by the server's access service,
which was true when it was written and is not now; main's replacement stands,
in this branch's vocabulary. The design-gaps row for groups was claimed by both
— real host groups here, per-vault headings there — and they are different
things, so both rows stay and the difference is stated: a group is a shelf the
user chose, a vault is who can read the item.

One defect the tests found and the compiler could not. Generating a key opens
the same editor as pasting one, but not through NewKey — so it never set the
target vault main added, and a generated key was filed into whatever vault was
edited last, or none. Both key-generation tests failed on it. Fixed where the
editor opens, with the reason recorded there.

One gap is left deliberately and is written down rather than half-built. Hosts,
keys, credentials and pins are read across every vault this session holds a key
for; groups are read from the active vault alone, so a host a teammate filed
shows under UNGROUPED. Nothing is lost or misfiled — it is what the sidebar
already shows for a group that has been deleted — but closing it needs a vault
id on every group row for rename and delete, and a way to tell two vaults'
identically-named groups apart under a layout with one heading per group. Both
are worth doing and neither is a merge's business. It is in the remarks on
ReloadGroupsAsync and in docs/design-import-gaps.md.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1282 tests, including the end-to-end suite against real containers.
2026-07-31 20:44:39 +02:00
jaap-jan d07b336868 Free the terminal from the Hosts screen, and fill the room it left
The WebView sat inside the Hosts grid, so navigating to Files or the keychain
hid every open terminal and the strip that named them. A connection you had
opened was invisible from four of the five screens. The window now has two
surfaces rather than one: a nav rail that says which page you are on, and a
terminal strip that is always there and switches the whole content area to a
shell. Screen keeps meaning "which page" and never becomes a sixth kind of
page, which is why this is two properties instead of one enum with a terminal
member in it.

Every screen lives inside one wrapper panel that collapses when a terminal is
showing. That is not tidiness — the WebView hosts a Win32 child window that
composites above everything Avalonia draws, so a screen left visible over its
rectangle is a screen sliced in half, and this window has shipped that defect
once already. One decision point, IsTerminalShowing, and a nested panel rather
than five compound bindings nobody would remember to extend.

The focus choreography is the part no test in this repo can see. Every reveal
path now focuses in the same turn the WebView appeared, so all three of them
post at DispatcherPriority.Loaded and let the native control re-push its bounds
first. Going the other way had a real bug: the screen-changed branch called a
bare Focus() where it had to release the keyboard from the native child, so
switching from a terminal to Files silently ate the first keystrokes. Rare
before this commit and the primary gesture after it.

The tab strip grew a cross inside each tab, a plus that opens the quick-connect
palette, and middle-click close. Nested buttons are correct here: Avalonia
handles a left press on the cross and deliberately does not handle other
buttons, which is exactly what lets middle-click bubble up from the cross as
well as the tab. The test is PointerUpdateKind rather than
IsMiddleButtonPressed, because the latter reports button state and is also true
for a left press made while the middle button happens to be held. The handler
is on the tab and not the strip, so the background closes nothing by
construction. Plus opens the palette rather than a flyout, since a menu
dropping into the WebView's rectangle may or may not composite above a child
HWND and this repo does not make rendering claims it has not photographed.

Everything a user reads now says keychain. The wire, the database and the
cryptographic spec still say vault, deliberately: renaming those is a migration
and a protocol change for a word. That split is written down rather than left
to be rediscovered as an inconsistency.

Four things that were squeezed into the keychain's category rail, or into
nothing at all, now have screens. Pinned host keys get one, with fingerprints
never truncated and a filter that matches them, because comparing what you have
against what the operator published is the whole workflow; the approved date is
read out of the item's UUIDv7 rather than added as a column, and says so, since
it means first approval and not last use. Keys can be generated in the client,
which needed the openssh-key-v1 container written by hand — there is no BCL or
NSec helper, and the PKCS#8 route is unverified in the SSH library this uses.
The armour carries no passphrase: encrypting it needs bcrypt_pbkdf, which is
Blowfish with a swizzle, in a project whose crypto is otherwise entirely
libsodium, for a protection the key's own remarks argue is redundant inside a
vault. Generation fills the existing editor and stops, so SAVE stays the one
thing that writes. ~/.ssh/config can be imported behind a preview that is
ticked per row and writes nothing until the button; IdentityFile records the
path and imports the key material only on an explicit opt-in, because reading
somebody's private key into a vault is precisely the act this product exists to
make deliberate. Match blocks and ProxyJump are reported rather than obeyed —
one cannot be evaluated statically and the other has nothing behind it to route
with, and a preview that implied otherwise would be worse than one that admits
it.

Files can be dragged in all four directions that are honestly available. Remote
to Explorer does not ship and is not pretended to: the shell wants the bytes
during the drop, which needs a virtual file and a native COM data object,
outside what Avalonia offers. Note for the next person that Avalonia 12
replaced the drag model outright — DataObject and DataFormats are no-op stubs
and IDataObject is not in the reference assembly, so every tutorial written for
11 does not compile here.

Hosts can be grouped, flat and never nested. A parent id merged as a scalar
lets two offline clients each re-parent A under B and B under A, producing a
cycle inside an encrypted payload that no server can police and every reader
would have to detect for ever. Membership lives in that payload rather than in
the one plaintext concession ADR 0001 allows, whose test is that the relay
cannot function without it — nothing on the server reads a group, so what
plaintext would hand over is a clustering of the estate for nothing. The
plaintext column reserved for it is dropped, provably always null, and the
server now refuses a client that sends one; it was never populated, was copied
on apply, and was not cleared on delete, so a group id would have outlived the
host it described.

Snippets insert through xterm rather than through the pump, because xterm is
the only thing that knows whether the remote has bracketed paste on, and that
is what makes a shell treat embedded newlines as text instead of as execute.
The host process moves opaque bytes and never parses output, so it would have
to guess, and guessing wrong runs every line. Running is off by default and the
copy says the text goes into whatever is there — the terminal has no notion of
being at a prompt, and may be in vi or at a password prompt with echo off, so
the Enter the user presses themselves is the entire safety property.

Connections and keychain changes are recorded as synced encrypted items, which
is what makes them auditable by a team later and costs the server knowledge of
connection rate and timing from row counts alone. ADR 0001 already concedes it
cannot hide that class of metadata; the trade is now written into it rather
than left implicit. A connection entry is written once, at close, which is what
makes a synced log tractable: nothing to merge, one outbox row, no chance of
colliding with itself. Live sessions come from memory, not from the log. The
write is void by contract and posts to a bounded channel, because putting an
encrypt-and-write on the teardown path of every session is how closing the
application comes to take four seconds. A ticket opened before a lock still
closes afterwards, since a shell outlives the vault. The activity log hooks the
one generic repository every kind writes through, so it cannot miss a caller —
which is also why the log kinds themselves declare they are not audited, or the
first entry would write an entry about writing an entry. It records the names
of the fields that changed and never their values; a log with an old password
in it would be a plaintext credential store with no vault around it. Retention
is 90 days or 5,000 entries, whichever bites first, pruned on the sync loop
rather than on a second timer.

That log traffic then broke the status line, which is worth recording because
the fix is a shape and not a patch: background sync counted its own log rows as
pushed items, so the quiet rule stopped being quiet and every action's message
was overwritten a second later by a sync report. The report now separates log
rows from user items and the rule reads the latter.

S3 buckets appear as a remote in the file browser, behind the same interface an
SFTP session implements, so the queue and both panes did not have to learn what
they are talking to. Uploads go through a pipe, because the queue wants to
write and the SDK wants to read; memory is then bounded by the part size
instead of buffering a file to disk twice.

Finally, the Windows device key store moved out of the session project, which
was the one thing keeping it from being portable — everything else in it is
platform-neutral, and a Windows CNG dependency in the middle of the vault code
meant a second head could not reference it without dragging Windows along. The
seam that made the move free was already there. docs/android-port.md is the
audit behind that: what ports, what does not, in order of cost, the four
decisions taken, and an inventory of every screen and state the interface has
to carry, written so a design can be made from it directly.

dotnet build, dotnet test and dotnet format --verify-no-changes are all clean:
1240 tests at zero warnings, including the end-to-end suite against real
containers. The manual checks that headless Avalonia cannot make — the drag
from Explorer, a generated key against a real host, twelve tabs at the minimum
window width — are listed in docs/manual-checks.md and are still outstanding.
2026-07-31 20:30:05 +02:00
jaap-jan 03e902a2d2 Colour the host's file rows by what their mode says
ci / build and test (push) Failing after 2s
The remote pane's NAME column was blue for a directory and plain for everything
else, and the PERMS column was faint whatever it said. Two colours now come off
the mode, split across those two columns on purpose: NAME says what a row is,
so a file with an execute bit is green there, and PERMS says what is notable
about how it is set, so a file anyone may write to is amber over the characters
that actually say so. Because the two never compete for one TextBlock, a
world-writable executable shows both facts instead of one winning an argument.
No new blue is spent, which is what App.axaml asks for: it reserves blue for a
directory, a distinct scope, and calls it deliberately rare.

Both are files only, and each exclusion is a wrong answer avoided rather than a
case not got to. Every symbolic link is lrwxrwxrwx by convention and its mode
governs nothing — what may be written is the target, whose mode an lstat
listing never fetched — so amber there would fire on every link on the host. A
world-writable directory is /tmp, made safe by a sticky bit PosixMode does not
render, and warning about it would be warning about the half of the mode that
is on screen while the half that answers the warning is not. And the execute
bit on a directory means "may be searched", which is true of very nearly every
directory a host has, so green there would paint the whole pane and mark
nothing.

The two questions read back the string PosixMode wrote rather than carrying its
nine booleans through SftpEntry as well. That is the point rather than a
shortcut: two representations of one fact is how a row ends up coloured for a
bit the column beside it does not show. A mode of the wrong length answers
false rather than throwing, since these decide a colour and a listing is not
worth failing over one.

The amber is Warn rather than WarnText, which is the muted amber a warning card
writes its sentences in. At 9.5px against TextFaint that one is a shade rather
than a signal, and a marker nobody notices is the same as no marker.

The local pane is untouched, on the grounds it already gives for having no
PERMS column at all: a POSIX mode is not a fact about a file on Windows, and
colouring one there would invent exactly what the column declines to print.

Twenty cases in RemotePathTests, which needs no container — the execute bit in
any of the three triples rather than only the owner's, the others-write bit
alone, a mode of the wrong length, and the file-only rule for both questions
from all three kinds. dotnet format is clean and the app and layout suites pass
at 109 and 35.
2026-07-31 12:17:56 +02:00
jaap-jan 04faef6597 Move files to and from a host over SFTP
M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a
transfer queue in a project of its own, and the two-pane browser the design
asked for replacing the screen that said it did not exist. Remote listings
carry names, sizes, modification times and a real drwxr-xr-x — nothing in this
repository could render a POSIX mode before — and the queue moves one file at a
time with progress, throughput and resume.

The design import assumed this would be an SFTP subsystem channel on
ISshConnection, beside the shell on a transport that is already up. SSH.NET
does not offer that: SftpClient derives from BaseClient and owns its own
transport, and there is no supported way to hand it an SshClient's session. So
file transfer opens a second authenticated connection, and it is named for
that rather than dressed up as a channel — OpenSftpAsync is on
ISftpSessionFactory, not on a connection. The difference is visible to a user:
the host records a second login, and a host whose password is typed each time
asks for it again on this screen. It goes through the same host key gate, the
same pin and the same two refusals a shell does, so a fingerprint approved for
a terminal is approved here and one approved here reaches the other machines
with the next sync. docs/design-import-gaps.md is corrected, and marked as the
one row where what shipped differs from what it predicted.

Nothing is written at its final name until it is complete. Every transfer goes
to a .dodossh-part file beside its destination and is renamed into place at the
end, so an interrupted transfer can never be mistaken for a finished one —
which matters most for what this screen is actually for, which is copying a
build artefact onto a server and then running it. A destination that already
exists is refused outright rather than overwritten: the queue has no way to
ask, and silently replacing a file somebody's process is serving is the worse
of the two failures. The remote pane has DELETE and MKDIR so that refusal is
not a dead end. A test against the container pins the assumption underneath all
of this — that SFTP's rename does not clobber.

Resume works within a run of the application and not across a restart, and the
limit is deliberate rather than unfinished. Nothing records which source wrote
a part file, and resuming one on the strength of its name matching is how a
corrupt artefact gets delivered with nothing reporting a failure; a part file
found at startup is started over. Making it survive a restart needs the
preferences store this client still has not got. The offset a resume starts at
is the part file's own length rather than the transfer's recorded progress: a
cancellation can land between a write completing and the counter moving, and
only one of those two is a fact about the bytes that are there.

The queue and its connection outlive a lock, as shells do. LockAsync already
argues that locking must not destroy work in flight — it is what somebody does
when they walk away from the machine, which is exactly when a long transfer is
most likely to be running — so TransfersViewModel is created once and the vault
is attached on unlock and detached on lock. What locking takes is the host
list, and it has to: those rows carry decrypted secrets.

DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The
two answer different questions — one is about reaching a host, the other about
moving bytes and what to do when moving them stops halfway — and this is the
only client project that deliberately touches the local filesystem.

Three defects the tests found, none of which review would have. SftpPath.Name
answered an empty string for the root. NavigateRemoteAsync wrapped itself in
the busy guard, so navigating from inside another command did nothing at all
and the remote pane simply stayed empty after connecting, with no failure
anywhere to explain it. And opening an SFTP session per test made two
handshakes per test — this client learns a host key by being refused — which
pushed the SSH assembly past sshd's MaxStartups and failed a different few
unrelated tests each run; the session is shared through the fixture now, with
the reason written where the next person will hit it.

1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against
the OpenSSH container, the queue against a real temporary directory and a fake
host, and three more layout measurements because a screen this window has never
laid out is a screen never checked.

Not verified: the screen has not been looked at running. The layout harness
measures it at the window's minimum in three shapes, which is the class of
defect that has shipped here before, but reaching it in the application needs
the compose stack, the migrations, the API and a browser sign-in. What is still
absent — the status bar's transfer count, dragging between the panes,
transferring a directory, and sftp over a bastion — is in
docs/design-import-gaps.md.
2026-07-31 11:07:29 +02:00
jaap-jan 94e11f5e38 update packages
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
2026-07-31 10:12:05 +02:00
jaap-jan 211eba0666 Keep host key trust in the vault, and make it withdrawable
ci / build and test (ubuntu) (push) Canceled after 0s
ci / build (windows) (push) Canceled after 0s
A fingerprint approved once is now approved on every machine and survives a
restart, because host key trust is a vault item type rather than a dictionary
that dies with the process. InMemoryKnownHostStore was what shipped, so the user
was asked to verify a fingerprint on every single connection — which is the gap
most likely to train somebody to click through the one warning that actually
matters. A warning that appears when nothing is wrong teaches that nothing is
ever wrong.

The fourth item type, and like the third it cost no sync logic: a row, an EF
configuration, a migration, a server kind; a secret, a codec, a merge, a cipher,
a repository facade and a session property. One row in the client registry. The
reconciler, the mirror, the repository, the outbox and the pull filter were not
touched. SyncEntityType.KnownHostKey and AadResourceType.KnownHostKey were
already reserved, so neither the contract nor docs/crypto.md changed.

One item per (host, port, algorithm), because a server legitimately offers
several host keys and which one gets negotiated is not ours to predict. Pinning
per endpoint would make an algorithm change indistinguishable from an attack.

The label is derived rather than stored, which is the one place this type
departs from the other three. A user never names a pin — there is nothing to
name it after but the three fields it already has — and a stored label is a
second copy of data that can disagree with the first after a merge. Relabel
returns the secret unchanged, and says why.

The store answers the handshake without touching the disk. SshNetConnectionFactory
calls FindAsync from inside SSH.NET's synchronous HostKeyReceived event, over
.GetAwaiter().GetResult(), which cannot be avoided; doing SQLite I/O plus an AEAD
open per lookup there would put the handshake behind the cache. So decryption
happens in OpenAsync and RefreshAsync — on unlock and after each sync pass,
exactly where the host and key lists already reload — and FindAsync is a
dictionary read under a lock with no await inside it.

That snapshot is where the one real bug in this change lived. Install originally
merged the live pins over the freshly loaded snapshot, to protect a TrustAsync
that had landed while the read was in flight. It would also have resurrected
every pin the user had just forgotten, and stopped a withdrawal made on another
machine from ever taking effect — the store would have healed the deletion back
into existence on every refresh. Replacing wholesale and discarding the read
instead is correct because writes are the rare case: every write bumps a
generation counter, and a refresh whose stamp is stale throws itself away rather
than winning. Nothing found this but reading the method again; it is the kind of
mistake that passes every test written before it, because the test that catches
it is the one the bug tells you to write.

Forgetting is new, and persistence is what made it mandatory rather than
convenient. A mismatch is a hard refusal with no way to continue — deliberately,
and that stays — so pinning a key permanently is also a way to make a
legitimately rebuilt server permanently unreachable. Before this change the pin
died at exit and the problem solved itself; now it does not.

ForgetAsync drops every algorithm for an endpoint, and it is reachable from the
host editor rather than from the warning. Putting it on the mismatch banner would
have made it two clicks from "this may be an attack" to "connect anyway", which
is the affordance the hard refusal exists to deny. The banner already promised
the key could be removed in the host's settings; that promise is now true and
points at the button.

Trust recorded on another machine becomes visible at the next sync pass, not
immediately, and that is a decision rather than an oversight. The failure it
produces is a first-contact prompt for a host a colleague approved a minute ago:
answerable, and self-correcting on the next pass. The opposite trade — polling
the vault on the handshake thread to close a one-minute window — buys nothing
and costs the property above. The dangerous direction is not reachable at all: a
pin recorded here enters the snapshot as part of recording it, so a refresh can
never discard a local trust decision.

The server learns nothing, and this is the item type where the temptation was
real. A plaintext host column would let a known-hosts screen sort and page
without decrypting anything, and it would hand the operator the map of every
user's estate — assembled, as these things are, out of facts that are each
individually harmless. A host row concedes an address only when relay is
switched on and the database refuses to store one otherwise (ADR 0004); there is
no equivalent excuse here. The table has no column to put one in, and the EF
configuration says so where somebody adding it would be standing.

Two things about the migration in this commit are worth knowing, because both
came out of getting it wrong.

It was hand-written first, including its .Designer.cs, and that version is not
what is here. Verifying it turned up something that had been quietly assumed:
Migration_AppliedCleanly_WithNoPendingModelChanges does not check the model
snapshot. It asserts that migrations applied and that none are pending, which a
wrong snapshot satisfies perfectly — the snapshot only matters as the diff base
for the *next* migrations add, so an incorrect one passes the whole suite and
corrupts the following migration instead. The real check is to generate a
throwaway migration and confirm its Up and Down come out empty. They did, and
the generated designer was byte-identical to the transcribed one across all 1255
lines, so the hand-written work was in fact correct.

Then dotnet ef migrations remove --no-build deleted the wrong migration. With
--no-build the tool reads the previously compiled assembly rather than the files
on disk, and the probe had just changed which migration was last, so it removed
AddKnownHostKeyItem and reverted the snapshot. That turned out to leave exactly
the right diff base, so the migration here is EF's own output rather than a
transcription — a better outcome than the one that was interrupted, arrived at
by accident. Never pass --no-build to migrations remove.

Mutation tested, all three sabotages detected: dropping the algorithm from
KnownHostIdentity.For, merging instead of replacing in Install, and pointing
KnownHostKeyCipher at PortForward — which is what a cast from the wire enum's 10
would silently produce. Each is caught both by an assertion about the mechanism
and by a behavioural test that never mentions it; the resource-type sabotage is
caught by the table from d10a38d and nothing else, which is what that table is
for.

The end-to-end slice now approves the real sshd's host key through the vault,
pushes it, and reads it back on the second simulated machine — including a check
that the server learned no address, and that the second machine answers null for
an algorithm never offered.

845 tests green. Zero warnings, dotnet format clean.

Three things are deliberately not fixed. A tombstone queued over a create that
was never pushed is refused by the server as Invalid and parked; that is
pre-existing for all four item types, and the fix belongs in
VaultItemRepository.DeleteAsync rather than here. Deleting a host, or changing
its address, orphans its pins — both are correct as trust decisions, since a pin
describes an endpoint and not a bookmark, but nothing surfaces the leftovers.
And there is no interface listing pins at all: trust is created at the connect
prompt and withdrawn in the host editor. A known-hosts list is where the orphans
would become visible, and it wants the vault column rework first, for the same
reason the credential editor does.
2026-07-30 11:00:39 +02:00
jaap-jan 5fccd53824 Add the Avalonia app and the xterm renderer, and fix two real bugs
The terminal works end to end. A new integration test drives a real sshd in
a container through a real PTY, the real pump, the real loopback WebSocket
with its token and origin checks, and a ClientWebSocket standing in for the
page: the login banner arrives, typed input round-trips, and `stty size`
reports the 100x30 the session asked for. The only untested link left is
xterm drawing bytes it was handed.

The WebView is de-risked on Windows, which was the plan's largest risk. Not
by assertion: with the app running there is an established TCP connection
from msedgewebview2 to the data plane port, so WebView2 launched, navigated
to the loopback page, executed terminal.js, and completed the WebSocket
handshake against the real token and origin checks. Linux remains unproven
and the package's own release notes now corroborate the concern -- Linux uses
a WPE backend, and it ships a NativeWebDialog described as useful where
embedded WebViews may be unavailable.

Two bugs found by building it, both of which would have shipped:

- ShellStream.Write buffers and needs an explicit Flush. Without one a
  keystroke is accepted, reported as written, and never reaches the remote:
  the terminal displays output perfectly and simply stops responding to
  input. SSH.NET's own WriteLine flushes, which is why the earlier spike
  never hit it. Found by isolating the pump against real SSH and reading
  BytesRead=51 -- banner and prompt through, nothing after.
- The Windows app manifest needs a supportedOS list, or Avalonia's native
  control host fails outright and the terminal never starts.

Also fixed a genuinely flaky test I happened to catch: SyncCursorTests
tampered with the *last* base64url character, whose low bits the decoder
ignores when the input length is not a multiple of three -- so a tampered
cursor sometimes decoded to identical bytes and verified. It failed roughly
one run in thirty, depending on a random key. Now tampers the penultimate
character, which is fully significant at every length; 40 consecutive runs
are clean.

xterm 6.0.0 plus the fit and webgl addons are vendored as UMD bundles rather
than built with npm, so a clean clone needs only the .NET SDK. Provenance
and licences are recorded next to them, along with the UMD global names
terminal.js depends on -- a bundle that switched to ES modules would load
without error and leave Terminal undefined.

The renderer acknowledges output from term.write's completion callback, not
on receipt. Acknowledging early would return flow-control credit for bytes
the screen has not caught up with, which is the one thing the credit window
exists to measure.

TerminalWorkspace moved into DodoSSH.Client.Terminal: it has no Avalonia
dependency, and having it there is what let the end-to-end test exist at all.

404 tests pass, zero warnings on a clean rebuild, format clean.
2026-07-28 22:30:42 +02:00
jaap-jan eb354bcdd9 Add the SSH session layer and the terminal data plane
The throughput harness the plan requires before any UI, plus the SSH
plumbing under it. 94 new tests, no WebView involved.

Credit-based flow control is what makes `yes` survivable. A terminal renders
at 60 Hz at best while a remote produces output as fast as the network
allows, and the difference has to accumulate somewhere or be refused.
Credit is reserved *before* reading, never after: because the pump cannot
read more than the renderer has room for, the coalescing buffer is bounded
by the window rather than by how fast the remote can talk. When credit runs
out the pump stops reading, SSH's own receive window closes, and the remote
sshd blocks -- backpressure to the source with no custom protocol.

Verified by falsification, not just by passing: with the credit gate removed
three tests fail, including the throughput harness's bounded-memory
assertion. Acknowledgements are clamped because they cross into JavaScript,
where a buggy or hostile page could otherwise claim to have rendered a
gigabyte and talk the host into an unbounded read.

Host key trust is enforced by *failing* the connection rather than
prompting inside the handshake. SSH.NET raises verification synchronously,
so consulting the user there would block the handshake on a UI round trip
and deadlock the first time the prompt needed the UI thread. Unknown host
and changed key become distinct exceptions the caller resolves
asynchronously. A mismatch has no retry path at all: a dialog offering to
continue is how users are trained to click through the one warning that
actually indicates interception. A legitimately rebuilt server is handled by
removing the pin in settings, away from the moment of connecting.

The data plane serves the renderer page from the same loopback listener as
the socket, which makes Origin predictable -- always http://127.0.0.1:{port}
-- where a WebView virtual-host mapping would give a different origin per
backend and nothing to validate. The token is substituted at serve time, so
it never touches disk and never appears in a URL. Being clear about what
that buys: not protection from a process running as this user, which can
read our memory anyway, but from a page in the user's browser attempting
WebSocket connections to loopback ports, which is a real and routine thing.

Two bugs the tests caught. The accept loop handled connections serially, so
an upgraded WebSocket parked it inside the receive loop and every later
request went unanswered -- the page's own script among them. The suite hung
rather than failed, which is how I found it. And SHA-1 is unavoidable here:
RFC 6455 mandates it for Sec-WebSocket-Accept, where it authenticates
nothing. Suppressed narrowly with that reasoning; the alternative,
HttpListener.AcceptWebSocketAsync, throws PlatformNotSupportedException off
Windows.
2026-07-28 21:58:55 +02:00
jaap-jan 885fb17bdc Clear the SSH gate: window-change reaches the remote, and licence as MIT
Licence is MIT, set solution-wide rather than only on the packable project:
DodoSSH.Contracts is published so clients can build against it, and a
package with no licence expression is one a corporate policy scanner
rejects outright.

The SSH.NET spike is the M1 client gate and it passes. SSH.NET 2025.1.0
exposes ShellStream.ChangeWindowSize, but a method existing is not the
remote observing it, so the tests read `stty size` back from a real sshd
after resizing rather than asserting the call did not throw. Repeated
resizes each take effect too, which matters because dragging a window edge
produces a stream of them. The IChannelSession fallback is not needed.

Also verified against a real sshd: password and public-key auth, that the
host key arrives as a raw blob we can fingerprint ourselves rather than
reading SSH.NET's MD5 property, and that refusing the key via CanTrust
actually aborts the connection -- without which the TOFU dialog would be
decoration.

Kept as a permanent suite, not deleted after the spike. An upgrade that
silently stopped sending the request would present as wrapped output only
after a resize, which is easy to misattribute to the terminal emulator.

Two bugs in the test itself, both worth naming because either would have
been read as "resize does not work":

- A PTY emits CRLF, and the anchored regex rejected the CR. The output
  visibly contained `24 80` while the match failed.
- Each read can begin with output still buffered from the previous command,
  including its size line. Taking the first match would have reported the
  pre-resize size.

platform-flags.md now records window-change as resolved rather than
unverified -- a stale flag is worse than none -- plus the three real SSH.NET
limits found on the way: ShellStream does not override ReadAsync so every
idle session parks a pool thread, one connection cannot serve both
SshClient and SftpClient, and agent forwarding needs an upstream change.
2026-07-28 16:52:09 +02:00