After a successful connect the first keystrokes went to the shell's UI rather
than the remote shell. The page's own term.focus() focuses the textarea inside
the document, which does nothing while the window's keyboard focus is still on
the Connect button, so the terminal had to be clicked before it would accept
anything.
The obvious guess about the fix — that reaching a native child window needs
SetFocus through P/Invoke — is backwards, and measuring it first is what kept
this small. NativeWebView overrides Focusable to true and its OnGotFocus calls
the adapter's Focus(), which on Windows is
ICoreWebView2Controller::MoveFocus(PROGRAMMATIC). So a plain Avalonia
Terminal.Focus() really does move Win32 focus into WebView2. Measured in a
standalone harness with no DodoSSH code, on the same 340,* grid as the shell,
reporting GetFocus() and the page's own document.hasFocus() at each step: focus
lands on the Chrome_WidgetWin_1 child and the page reports hasFocus: true.
It is the return trip the package does not implement. OnLostFocus calls the
adapter's ResignFocus(), and on Windows that method body is empty, so Avalonia's
focus and Win32's diverge: after textBox.Focus() the focused element is the text
box while the keyboard is still on WebView2 — a caret that silently receives
nothing. Window.Activate() and Window.Focus() were both measured and neither
recovers it, so the hand-back is a SetFocus on the top-level, in
Views/NativeKeyboardFocus.cs. A real mouse click does recover it, because
Avalonia's window sets focus on pointer input, which is why this is invisible to
anyone who clicks before typing.
That turned up a worse defect than the one being fixed, and it shipped in
0500e43. Collapsing the WebView does not release the keyboard: focus stays on
the hidden holder — measured held by a window reporting visible=False — while
Avalonia's focused element becomes (none). So a user who had clicked the
terminal and then pressed Lock got an unlock screen that swallowed the
passphrase. Locking now hands the keyboard back and focuses that box.
Ctrl+Shift+F6 is the way out for someone using only a keyboard. It has to be
handled in terminal.js and posted to the host as a web message, because once the
child window owns Win32 focus Avalonia receives no key events and no KeyBinding
could fire; the package also subscribes MoveFocusRequested and discards it, so
there is no Tab-out to lean on. Not Escape, which vim alone rules out, and not a
bare F6, which TUIs bind — Ctrl+Shift is the range terminal emulators
conventionally keep for themselves and never forward to the remote. Verified
rather than assumed: the posted string arrives verbatim in Body, and the chord
reaches the page as F6 with both modifiers.
Order matters and is now recorded. Focus() on a collapsed control is a measured
no-op and is not replayed when it is revealed, so focus survives a lock/unlock
cycle only because a session can be opened solely from an unlocked vault, which
is what reveals the control in the first place.
The view models still reference no view. VaultViewModel raises SessionOpened on
the success path only, the shell forwards it as TerminalSessionOpened through the
generated OnVaultChanged hook so unlock, lock and dispose all attach and detach
in one place, and the view holds the whole focus policy. An event rather than a
bound flag because connecting a second host while one is open has to move focus
again, and no state change describes that.
Three tests, and what they do not cover is the point. They cover the plumbing:
focus is asked for once per session, a failed connect does not ask at all — a
host-key prompt needs the keyboard on its own buttons — and locking stops the
forwarding. They cannot cover the focus call, because headless Avalonia has no
native window, so a headless test would focus correctly and confirm the wrong
belief; that is measured in the harness and written down in docs instead.
Dropping the forwarding fails two of them and dropping the detach fails one;
deleting the raise outright does not compile, since the event would be unused.
Reaching the connect path at all needed two new fakes. FakeRenderer attaches the
way the real page does — fetch the served page, read back the token and socket
URL the host substituted into it, then open the socket with both subprotocols —
rather than being handed the token, so the part of the handshake that has been
got wrong before stays under test. FakeSsh replaces a factory that would need a
reachable sshd, which DodoSSH.Client.Ssh.Tests already covers against a
container. The suite also never called workspace.Start(), so nothing served the
page and no renderer could have attached.
DllImport rather than the source-generated LibraryImport, which requires
AllowUnsafeBlocks for the whole project. The signature is blittable so there is
no marshalling stub to improve on, and turning unsafe code on across a client
that handles key material to gain nothing is a poor trade.
Correcting an earlier entry: docs/platform-flags.md described this as a
focus-plumbing gap and offered "click inside the terminal first" as the
workaround. Both true, and both stop short of the half that matters — focus
crosses into the WebView readily and never comes back on its own, which is the
same mechanism as the text boxes that mysteriously stopped accepting keystrokes
in the airspace entry above it, not a separate fault.
DodoSSH
A self-hosted, team-oriented SSH client with an end-to-end encrypted vault.
Manage hosts, credentials and keys in a desktop app; sync them across your devices and share them with teammates through a server you run yourself. The server stores ciphertext and never holds a key — the operator cannot read the credentials it stores.
Status: early development. See the milestone plan for what exists today.
Why
Teams either scatter SSH credentials across individual ~/.ssh directories with no sharing
story, or pay per-seat for a hosted product that holds their infrastructure credentials.
DodoSSH keeps the convenience of a synced, shareable vault while remaining self-hostable and
zero-knowledge.
Architecture
| Component | Choice |
|---|---|
| Backend | ASP.NET Core on .NET 10, PostgreSQL + EF Core |
| Client | Avalonia (C#) for Windows/Linux/macOS; terminal pane is a WebView running xterm.js |
| Auth | OIDC, provider-agnostic (Entra ID, Keycloak, Auth0, Authentik) |
| Vault | End-to-end encrypted; X25519 + Ed25519 + XChaCha20-Poly1305, Argon2id unlock |
| Connections | Client-direct SSH by default, with an optional raw-TCP server relay |
Two consequences worth knowing before you read further:
- Revocation is not retroactive. A removed member keeps what they already downloaded. The real remediation is rotating the SSH credential, so offboarding is built around a rotation checklist rather than a button that implies more than it delivers.
- No session recording in relay mode. The relay forwards SSH ciphertext, so it cannot see commands. That is the cost of the relay not being able to read your traffic.
The reasoning behind each major decision is recorded in docs/adr/, starting with
the E2EE trust model.
Repository layout
src/
DodoSSH.Contracts DTOs shared with the client — the real API contract
DodoSSH.Crypto DSH1 envelope, AAD derivation, the key hierarchy
DodoSSH.Domain entities and invariants, no EF
DodoSSH.Infrastructure DbContext, configurations, migrations
DodoSSH.Api the server
DodoSSH.Client.Auth OIDC code+PKCE on a loopback redirect, and the key binding
DodoSSH.Client.Api the typed server client, and client-side enrollment
DodoSSH.Client.Domain the decrypted item model and the three-way merge — no I/O at all
DodoSSH.Client.Storage the local cache: ciphertext mirror, outbox, offline unlock material
DodoSSH.Client.Sync the pull/apply/push loop and the conflict policy
DodoSSH.Client.Session where a profile lives, unlocking it, and getting one in the first place
DodoSSH.Client.Ssh connections, PTY shells, host key trust
DodoSSH.Client.Terminal the loopback data plane and credit-based flow control
DodoSSH.Client.App Avalonia; the only project that knows about a UI toolkit
tests/ one test project per source project
docs/adr/ architecture decision records
Everything under src/DodoSSH.Client.* except App is deliberately free of Avalonia. That is the
seam that lets the SSH layer, the terminal's flow control and the OIDC flow be tested without a UI
toolkit or a browser engine — which is most of why they are testable at all.
Building
Requires the .NET SDK pinned in global.json (10.0.x).
dotnet build DodoSSH.slnx
dotnet test DodoSSH.slnx
The tests need a Docker daemon. Everything that touches the database, the identity provider or an SSH server uses Testcontainers rather than a stub or a shared instance, so there is nothing to start first and nothing to clean up after — but with no daemon those suites fail rather than skip.
Running it
Four commands, in order. The first two are once per machine.
1. The development dependencies — PostgreSQL and Keycloak, with the dodossh realm imported:
docker compose -f deploy/docker-compose.dev.yml up -d
2. The schema. The API never migrates anything: it fails readiness while a migration is pending, and
says which one. dotnet-ef is pinned in .config/dotnet-tools.json, so run dotnet tool restore first if
you have not:
dotnet ef database update --project src/DodoSSH.Infrastructure
With nothing else configured this targets the compose stack above. Set DODOSSH_DESIGN_CONNECTION to point
it at another database.
3. The server:
dotnet run --project src/DodoSSH.Api
It listens on http://localhost:5233, serving /healthz/live, /healthz/ready and — in
Development — /openapi/v1.json.
4. The desktop client:
dotnet run --project src/DodoSSH.Client.App
In the app, enter http://localhost:5233 as the server. Your browser opens for sign-in — the realm ships
alice / alice — then choose a vault passphrase and write down the recovery code, which cannot be
skipped and cannot be recovered from the server. You can then add a host and open a shell on it. Keycloak's
admin console is at http://localhost:18080 (admin / admin).
Three of M1's known gaps are visible immediately, so they are worth expecting rather than diagnosing: a connection asks for the host's password every time, because credentials are not a synced entity type yet; host key trust lasts one session, because known hosts do not live in the vault yet; and unlock asks for the passphrase on every launch, because no device key is registered.
End-to-end verification
One suite runs against a real server rather than a stub. It needs a Docker daemon and nothing else, so it is part of the ordinary test run:
dotnet test tests/DodoSSH.SystemTests
It brings up PostgreSQL, Keycloak and an OpenSSH server in containers, applies the committed migrations,
starts the API as a child process out of its own build output, and then drives the real client: sign in
through Keycloak, enroll, unlock, create a host, sync it, read it back on a second simulated machine,
unlock again with no network, and open a shell on the sshd. Roughly 25 seconds once the images are
pulled.
What makes it worth its weight is that it consumes the artefacts that ship — the realm file from
deploy/keycloak, the EF migrations, the API's own appsettings — rather than a fixture written to match
them. On its first run it found a loopback redirect URI the realm registered in a form Keycloak rejects,
and a JSON configuration gap that made the whole sync surface unreachable from the real client while every
other test passed. Both are the same class of bug: two sides of a stub agreeing with each other about
something the specification never said.
The one value it cannot take from a committed file is Oidc:Authority, since the container's port is
assigned at start. Everything that authority points at is still the real realm.
Development and testing are currently Windows-only. Anything known or suspected to differ on
Linux and macOS is tracked in docs/platform-flags.md, along with the
deployment gotchas that have already cost time once. Read it before assuming something works
off-Windows.
Conventions the build enforces
- Warnings are errors.
dotnet format --verify-no-changesgates CI. - Package versions are centralised in
Directory.Packages.props;packages.lock.jsonis committed and CI restores in locked mode. BannedSymbols.txtbansDateTime.UtcNow(useTimeProvider),Guid.NewGuid(useCreateVersion7), sync-over-async, MD5/SHA1 and PBKDF2.- Public members of
DodoSSH.Contractsmust be declared inPublicAPI.Unshipped.txt, so a contract change is a build error rather than a client-side surprise.
Milestones
-
M0 — foundation. Repo structure, build conventions, CI, ADRs. Done.
-
M1 — vertical slice. OIDC login → enroll → create a host → open a shell. Server done: the DSH1 crypto core, the data model, sync push/pull for hosts,
/me, and enrollment with the identity-provider key binding. Client done: the key hierarchy, the OIDC flow with the key binding, SSH connections with host key trust, the terminal data plane, the encrypted local cache with the sync client — offline unlock, an outbox and a field-level three-way merge, conflict matrix green — and an Avalonia shell that is vault-backed: server URL → browser sign-in → enroll → unlock → host list → terminal. The shell's state machine is covered by tests against an in-memory server, so the states that matter most (the recovery code that cannot be skipped, the unlock that needs no network) are checked rather than remembered.Its layout is not covered by anything, and that gap has already cost a shipped defect: the setup and unlock screens were layered over the terminal's WebView, which on Windows is a native child window that cannot be covered, so they rendered sliced with their buttons unclickable. No test in this repository loads a
.axamlfile, and a headless one could not have caught this — there is no native window in headless, so it would have rendered perfectly and confirmed the wrong belief. Screens get looked at, or they are unverified. Verified end to end:tests/DodoSSH.SystemTestsdrives the whole slice against a real Keycloak, a real API, a real PostgreSQL and a realsshd— sign-in, the identity-provider key binding, enrollment, offline unlock, a host through the vault to a second machine, and an interactive shell. See End-to-end verification.Known gaps in the client, stated rather than implied by the interface: credentials are not a synced entity type yet, so a connection still asks for a password; known host keys live in memory for one session instead of in the vault; and no device key is registered, so the passphrase is needed on every launch until the OS keystore is wired.
-
M2 — full personal vault, robust sync, relay.
-
M3 — teams, sharing, ACLs.
-
M4 — hardening and ops, packaging, self-hosting guide.
-
M5 — multi-provider OIDC, key rotation, per-item content keys.
Licence
MIT.