Files
DodoSSH/docs/platform-flags.md
T
jaap-jan 49f617b450 Wire the Avalonia shell to the vault
The host list now comes from the vault instead of from a form. A fresh
machine takes a server URL, signs in through the browser, enrolls, and
from then on opens with the passphrase alone.

DodoSSH.Client.Session is the composition layer: where a profile lives,
how it unlocks, and how a machine gets one. ClientPaths picks a
non-roaming per-OS directory — %LOCALAPPDATA% and never %APPDATA%,
because a SQLite cache that roams between two machines is a corrupt one,
and each machine's outbox is its own. SessionOpener needs no transport at
all and could not reach one if it wanted to; that is the offline unlock,
asserted rather than asserted about. A wrong passphrase, a stale KDF and a
grant revoked by a rekey are three different answers, because the remedies
are three different things and telling someone to retype a passphrase that
was never the problem is worse than saying nothing.

The shell's states are the onboarding story. The recovery code gets its
own state that cannot be clicked past: it exists for one moment, losing it
with the passphrase loses the vault, and there is no server-side reset by
design. It is dropped from memory on confirmation rather than merely
hidden.

Sign-in is a delegate over IVaultServer, so the whole state machine runs
in a test against an in-memory server — no browser, no identity provider,
no toolkit. The view models are plain observable objects, which is what
makes that possible. What it does not cover is whether the XAML binds to
the right names; that needs a rendered tree and Avalonia.Headless, and is
its own piece of work.

Three things found by doing it rather than by reading it:

- Pooled SQLite connections keep the database file open after the last
  context is disposed. On Windows that means locked, so the application
  could never replace its own cache — and a test could not clean up after
  itself, which is how it surfaced. Dispose now clears the pool.
- EF's SQLite provider puts the database in WAL mode, so the cache is
  three files. A comment in ClientCacheFactory claimed the opposite;
  reading PRAGMA journal_mode off a real launch settled it. WAL is the
  right mode here — a sync pass writes while the interface reads — so the
  comment was wrong on the merits as well as on the fact.
- Enrolling a device key with nowhere to keep the private half would put a
  wrap on the server nobody can open and make the device list claim this
  machine can unlock without a passphrase. Device binding is now optional
  and the shell declines it until the OS keystore is wired.

Verified on Windows: the client created %LOCALAPPDATA%\DodoSSH\cache.db
and migrated it on first launch, and msedgewebview2 held an established
connection to the data plane while the unlock overlay covered it — which
is the point of covering the WebView rather than collapsing it, since a
NativeWebView that is never laid out is never realised.

630 tests, up from 593. The recovery-code gate and the offline unlock were
each verified by breaking them and watching the right test fail.

Still to do for M1's actual definition of done: the manual run against the
real API and a real Keycloak. Credentials are not a synced entity type
yet, so a connection still asks for a password, and the interface says so
rather than implying otherwise.
2026-07-29 11:02:19 +02:00

193 lines
13 KiB
Markdown

# Platform flags
Things known or suspected to behave differently outside Windows, plus deployment gotchas that
have already cost time once. **Development and testing are currently Windows-only**, so anything
here marked *unverified* has not run on the platform in question and must not be assumed to work.
Each entry says what the risk is, why it matters, and what to do about it. Delete an entry when it
has been verified or made moot — not when it merely stops being convenient.
## Cryptography
**`ChaCha20Poly1305.IsSupported` is false on macOS**, and on Windows builds before 10.0.20142.
This is why the client uses NSec (libsodium) rather than the BCL for content encryption; see
docs/crypto.md §1. *Already mitigated* — but if a BCL AEAD path is ever added as a fallback it
**must** gate on `IsSupported` rather than assuming availability, or the client will fail to open
any vault on macOS.
**Argon2id timings are measured on one Windows machine only.** 256 MiB with t=4 took 323 ms here.
The floor and ceiling in `EnrollmentLimits` were chosen against that number. *Unverified
elsewhere:* recalibrate on the slowest target platform before recommending a default profile,
because a cost that is comfortable on a desktop can make unlock unusable on a low-power laptop —
and the parameters are stored per user at enrollment, so a bad default is a per-user migration.
**libsodium ships native binaries per RID.** This complicates single-file and AOT publishing, and
on macOS every native library (`libsodium`, `libSkiaSharp`, `libHarfBuzzSharp`, `libe_sqlite3`)
must be signed **individually** with `--options runtime --timestamp` before the bundle is signed,
or notarization fails with an error that does not name the offending file.
## Desktop client
**The WebView works on Windows.** `Avalonia.Controls.WebView` 12.0.1 (MIT, no licence key) hosts the
terminal page successfully: WebView2 launches, navigates to the loopback page, runs its JavaScript and
completes the WebSocket handshake. Verified by observing an established TCP connection from
`msedgewebview2` to the data plane port.
**The Windows app manifest must declare a `supportedOS` list.** Without it the process reports a
downlevel Windows version and Avalonia's native control host fails outright — *"Unable to create child
window for native control host"* — so the WebView, and therefore the terminal, does not start at all.
`[STAThread]` on `Main` is equally mandatory: WebView2 checks the apartment state and refuses to
initialise on an MTA thread.
**WebView2 spawns a process tree, not a process.** Around 35 processes were observed for one embedded
view. That is the concrete reason the design uses one WebView hosting N terminals rather than one per
tab: twenty tabs would mean twenty of those trees.
**The Avalonia WebView on Linux remains unproven, and is still the largest risk in the plan.** The
package's own release notes say `NativeWebView` gained Linux support via a **WPE** backend
(`libwpewebkit-2.0`), which is much less widely installed than WebKitGTK — and it ships a separate
`NativeWebDialog` described as *"particularly useful for platforms like Linux where embedded WebView
controls might not be available"*, which is the vendor confirming the concern. *Unverified:* a spike
must cover Ubuntu on both Wayland and X11, Fedora KDE, and macOS 15. This is why the terminal sits
behind `ITerminalHost`; that seam should not be collapsed away for convenience.
**`Avalonia.Diagnostics` has no 12.x release** (latest is 11.3.18), so the developer tools overlay is
unavailable on Avalonia 12. Development-only, so nothing ships differently — but debugging a layout
problem currently means reasoning rather than inspecting.
**The xterm bundles are vendored, not built.** `@xterm/xterm` 6.0.0 with the fit and webgl addons, all
MIT, committed as UMD bundles under `WebAssets/vendor` and embedded as Avalonia resources. No npm or
esbuild step, so a clean clone builds with the .NET SDK alone. The cost is that upgrades are a manual
re-download; the licence and versions are recorded here so that stays visible.
**SSH.NET's `window-change` is verified working** as of 2025.1.0 — resolved, not a flag.
`ShellStream.ChangeWindowSize(columns, rows, width, height)` exists and the remote genuinely
observes it: `PtyAndResizeSpikeTests` reads `stty size` back from a real sshd after resizing, and
repeated resizes each take effect. The `IChannelSession` fallback is not needed. That suite stays
in place as a regression guard, because an upgrade that silently stopped sending the request would
present as wrapped output only after a resize — easy to misattribute to the terminal emulator.
**`ShellStream.Write` buffers and requires 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 a spike that used it never
hit this. `SshNetShellSession.WriteAsync` now flushes per write; batching would be wrong anyway, since
a terminal has to put a keystroke on the wire immediately.
**`ShellStream` does not override `ReadAsync`.** The base `Stream` implementation therefore runs
the blocking `Read` on a thread-pool thread, so every open session parks one thread for as long as
it is idle. Fine for the handful of tabs M1 targets; revisit before advertising many concurrent
sessions, since the fix is either an upstream change or driving `IChannelSession` directly.
**SSH.NET cannot share one connection between `SshClient` and `SftpClient`.** A shell plus SFTP to
the same host means two TCP connections, two authentications and — later — two relay sockets.
Connect SFTP lazily and reuse the cached decrypted credential so the user is not prompted twice.
**Agent forwarding is de-scoped from v1.** It needs an upstream SSH.NET change. A vault-backed
agent of our own plus ProxyJump covers the real use cases.
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
for Windows/macOS/AppImage; Flatpak and deb/rpm defer updates to the package manager.
**Linux ships AppImage and Flatpak first**, specifically so the WebKit runtime is bundled rather
than assumed present on the user's machine.
**Opening the system browser depends on the platform handler.** `SystemBrowserLauncher` uses
`UseShellExecute`, which delegates to `ShellExecute` on Windows, `open` on macOS and `xdg-open` on
Linux. *Unverified off Windows:* `xdg-open` comes from `xdg-utils`, which is not guaranteed on a
minimal desktop or inside a Flatpak sandbox — where the portal is the correct route instead. If
sign-in silently does nothing on Linux, this is the first thing to check. `IBrowserLauncher` exists
so a platform-specific opener can be substituted without touching the flow.
## Local cache
**The cache location is per-OS and must stay non-roaming.** `ClientPaths` chooses it:
`%LOCALAPPDATA%\DodoSSH` on Windows, `~/Library/Application Support/DodoSSH` on macOS,
`$XDG_DATA_HOME/dodossh` or `~/.local/share/dodossh` on Linux. It must **not** land anywhere that syncs
to a cloud drive or roams: two machines writing one SQLite file through a file-sync client corrupts it,
and the whole point of the outbox is that each machine has its own. That is also why Windows uses
`%LOCALAPPDATA%` and not `%APPDATA%`, which roams in a domain environment.
The platform branches are explicit rather than delegating to
`Environment.SpecialFolder.LocalApplicationData` everywhere, because on macOS the runtime maps that to
`~/.local/share` rather than to `~/Library/Application Support`. *Verified on Windows only* — the client
created `%LOCALAPPDATA%\DodoSSH\cache.db` and migrated it on first launch. The macOS and Linux branches
are reasoned, not run.
**SQLite timestamps are stored as integers, deliberately.** EF's default `DateTimeOffset` mapping for
SQLite is a text form it then refuses to order or compare, so any query that sorts or filters by time
throws at execution rather than at model build. `UnixMillisecondsConverter` is applied as a convention
so a timestamp added later cannot be the one left unconverted. This is provider behaviour, not
platform behaviour, but it cost a debugging session and will again if the converter is removed.
**The cache is three files, not one.** EF Core's SQLite provider puts the database in WAL mode, which is
the right mode here — a background sync pass writes while the interface reads, and under the default
rollback journal those reads would fail busy — but it means `cache.db` is accompanied by `cache.db-wal`
and `cache.db-shm`. Any backup, export or uninstall routine that touches only `cache.db` is wrong.
Verified by launching the client and reading `PRAGMA journal_mode`, after a comment in the code claimed
the opposite.
**Pooled SQLite connections keep the file open after the last context is disposed.** On Windows that
means locked, so the application cannot delete or replace its own cache and a test cannot clean up after
itself. `ClientCacheFactory.Dispose` clears the pool for exactly this reason; removing that line makes
the failure appear only on Windows.
**No SQLCipher, on any platform.** The rows are already ciphertext from the server, so an encrypted
database file would protect bytes that are protected already at the cost of a native dependency and a
licence obligation — and `bundle_e_sqlcipher` was deprecated in SQLitePCLRaw 3.0. The consequence to
be honest about: the cache offers no protection against another process running as the same user. See
`LocalCacheProtector` for what it does and does not defend against.
**A `NativeWebView` that is never laid out is never realised.** The shell covers the terminal with its
setup and unlock screens rather than collapsing it with `IsVisible`, because the control hosts a real
child window and hiding it would leave the terminal blank on the first connection after unlocking.
Verified on Windows: with the unlock overlay showing, `msedgewebview2` still had an established
connection to the data plane port, so the page had loaded and completed its WebSocket handshake.
## Build and CI
**Integration tests need a Docker daemon** (Testcontainers). They run on `ubuntu-latest` in CI.
macOS runners have no Docker daemon, and the Windows CI job is deliberately build-only. So
anything proved by an integration test is proved on Linux only — which is the right place for
server code, and no coverage at all for client platform behaviour.
**`[CallerFilePath]` is rewritten to `/_/...` under `ContinuousIntegrationBuild`.** Any test that
locates a fixture by source path passes locally and fails in CI. Copy fixtures to the output
directory and read them via `AppContext.BaseDirectory` instead; `GoldenVectorTests` shows the
pattern.
**`dotnet format --verify-no-changes` is part of the CI gate** and exits non-zero on style
warnings, not just whitespace. Run it before pushing; a build with zero warnings can still fail
that step.
## Deployment
**PostgreSQL 18 moved its data directory** to `/var/lib/postgresql`, not `/var/lib/postgresql/data`
as in 17 and earlier. A compose file carried over from an older version silently gets an empty
volume — the database appears to work and loses everything on restart. Relevant to any compose
file other than `deploy/docker-compose.dev.yml`, which is already correct.
**Keycloak in the dev stack listens on host port 18080, not 8080.** On this machine an unrelated
Apache Tomcat holds `127.0.0.1:8080`, and a loopback-specific bind wins over Docker's `0.0.0.0`
publish when resolving `localhost` — so every realm request returned 404 while the container
looked healthy. If discovery fails against a locally-published container, check for another
process bound specifically to loopback before suspecting the container.
**`Sync:CursorSigningKey` generates an ephemeral per-process key when unset.** Fine for a single
node; on a multi-node deployment cursors issued by one node are rejected by another, so clients
resync from the beginning repeatedly. Must be configured explicitly before running more than one
instance. `WarnOnRiskyConfiguration` logs this at startup.
**Rate limiting is not implemented yet** (M2). `POST /api/v1/me/enrollment` and the sync endpoints
are reachable by any authenticated caller at any rate. Enrollment requires a valid access token
and is idempotent, so the exposure is resource consumption rather than a credential-guessing
surface — but it is still an unmetered write path.
**`/api/v1/me` does not update `last_seen_at_utc`.** Deliberate: a GET that writes on every call is
a smell, and nothing depends on the value yet. Revisit when device management lands, since that is
the first feature that needs it.