implementation: current-as-of-2026-09-02
Architecture
server-watcher (sw) manages the user's server infrastructure from the one
source of truth the user already maintains: ~/.ssh/config. This document is
the architecture index; per-area contracts land as follow-up documents and
must be linked from here.
Single workspace, eight crates
One Cargo workspace, one version of every dependency, one set of gates. The
split follows a single rule: pure vocabulary in sw-core, one crate per
runtime concern, nothing shared by accident.
graph TD
cli[sw-cli<br/>`sw` binary: serve / ui / mcp / sync / token]
api[sw-api<br/>axum: REST + WS terminal/events + auth + embedded UI]
mon[sw-monitor<br/>sync service + scheduler + retention]
mcp[sw-mcp<br/>stdio JSON-RPC MCP server]
ssh[sw-ssh<br/>system ssh: executor / probe / metrics / PTY]
store[sw-store<br/>SQLite WAL + migrations + repositories]
core[sw-core<br/>domain types + DTOs + ssh-config parser + error]
ui[sw-ui<br/>Leptos CSR, built by trunk into dist/]
cli --> api
cli --> mon
cli --> mcp
api --> mon
api --> ssh
api --> store
api -.embeds.-> ui
mcp --> store
mcp --> ssh
mon --> ssh
mon --> store
ssh --> core
store --> core
api --> core
mcp --> core
Layering rules:
sw-coredepends on nothing internal and no async runtime; it must stay compilable for wasm so the UI can reuse the DTOs and the ssh-config parser.sw-storeis the only crate that talks SQL; repositories speak sw-core types only.sw-sshis the only crate that spawnssshor opens sockets.sw-uiis a leaf: nothing in Rust depends on it — its build output (crates/sw-ui/dist) is the artifact, embedded bysw-api.
Run modes
The single sw binary exposes:
| Mode | What runs | Phase |
|---|---|---|
sw serve | axum API + embedded dashboard + the sw-monitor loops (sync, scheduler, retention) | 1–2 |
sw ui | the desktop shell (default when no subcommand is given): everything serve runs, plus a Tauri webview window and a status-item menu | 6 |
sw mcp | stdio JSON-RPC MCP server over the same SQLite file | 5 |
sw sync [--dry-run] | one-shot ~/.ssh/config sync into the store | 2 |
sw token | print a generated bearer token | 1 |
sw mcp is a separate process by design: an MCP client owns the process's
stdio, so the server cannot share a process with the HTTP server. Both
processes use the same SQLite database; WAL mode is what makes that
concurrent access safe.
System ssh reuse
The fleet is reached through the system ssh binary, never through an
embedded SSH library. Rationale: the user's ~/.ssh/config (aliases,
ProxyJump chains, Include files), known_hosts, agent and keys keep
working unchanged — there is no second source of truth for credentials, no
key material in the tool's own config, and no per-host connection setup to
maintain. The costs are explicit: process spawn per operation, output is
text to be parsed, and remote-side behavior depends on the installed ssh.
Both are acceptable at this scale (tens of hosts, minutes-scale polling).
Write path: config editing, keys, audit, purge
Phase 6 made ~/.ssh/config bidirectional: the dashboard (and the MCP
tools) can add, update and delete host stanzas through a stanza-splicing
editor that preserves every byte it does not replace. The same round added
SSH key management (scan / generate / delete / deploy), an append-only
audit log for every mutation, and a hard-purge path for stale hosts. The
full contracts — concurrency model, backup policy, deploy idempotency,
purge rules — live in 003-write-and-audit.md.
Two cross-cutting invariants are architectural, not per-feature:
- One editor — all config writes go through
sw-core's pureedit_config(String → String, no fs, wasm-compatible);sw-monitor'sConfigWriterowns the file lifecycle: read → edit → rollingconfig.sw.bakbackup → tmp(0600)+rename atomic write, all under a cross-processFile::lockon<config>.sw.locksosw serveandsw mcpcannot interleave read-modify-write cycles. - Every mutation is audited — config edits, key operations and host
purges insert an
audit_logrow tagged with the caller's source (api/mcp); the dashboard's/auditpage is a plain reader over it.
Localhost trust model
Two layers, no user accounts:
- Bind guard —
sw servebinds loopback only and refuses any other bind target. The API is local by construction. - Bearer token — every non-asset request must present the token
sw tokenprints (alsosw mcp, via environment). This keeps other local processes and drive-by web pages from driving the API, reading fleet data, or opening terminals.
The WebSocket terminal route carries the same token requirement; terminal
input is written to the PTY verbatim — the remote shell is the user's own.
The event stream route (GET /api/v1/events) authenticates the same way
(bearer header, sw_token cookie, or ?token=, plus the host guard):
authenticated BEFORE the upgrade, so a rejected handshake is a plain 401/403,
never a dropped socket.
Storage
One SQLite database in WAL mode, embedded migrations compiled in via
sqlx::migrate!, all queries runtime-checked (query/query_as functions,
no query! macros — nothing reads DATABASE_URL at compile time).
Aggregates: hosts (from sync), status history, metrics samples, exec
records, sync runs, and the append-only audit_log (migration 0003) that
records every config/key/purge mutation with its source (api / mcp).
Retention (sw-monitor) prunes and downsamples so the file stays bounded on
an always-on machine. Both pool connections enable foreign_keys(true),
which is what makes ON DELETE CASCADE real: purging a host row drops its
status/metrics/exec history with it.
Frontend and desktop shell
- UI — Leptos CSR built by trunk
0.21.14, pinned. Trunk0.22is a beta whose MSRV (1.96.1) is above this workspace's pinned Rust (1.93.1) — that combination cannot compile, which is exactly why the pin is explicit here and in the Phase 3 plan. Output lands incrates/sw-ui/dist, embedded bysw-apiwith rust-embed (mime-guesson for content types). The dist output is committed (CI never runs trunk): rebuild withtrunk build --releaseincrates/sw-ui, commit the hashed assets, and rebuild so rust-embed picks them up. The wasm entry is declared inindex.htmlas<link data-trunk rel="rust" href="Cargo.toml" />— a<script data-trunk src="*.rs">marker would take the script-asset pipeline and copy the Rust source verbatim. - Live updates — the monitor and sync publish
FleetEvents (status flips only, metrics samples, applied syncs) on atokio::sync::broadcastchannel created insw-cliand shared with the API state;sw-apiforwards them to subscribers of/api/v1/eventsas TEXT frames, and the dashboard bumps its poll tick on every frame, so live data lands without waiting for the next 10-second poll (the poll stays as the fallback; the client reconnects with capped exponential backoff).sw-monitorholds only anOption<Sender>— it never depends onsw-api, and callers that take no channel (MCP, tests) are unaffected. - Desktop shell —
sw uiruns the same axum server inside a Tauri v2 shell. Thread topology is fixed by AppKit: the Tauri event loop owns the main thread (which is whymaininsw-cliis a plainfn, not#[tokio::main]), while the store, monitor scheduler and axum run on a dedicated tokio runtime created insrc/ui.rs. Startup is ordered so atauri-plugin-single-instanceduplicate exits inside plugin init (never touching the shared port) andsetuponly builds the webview window after the listener is bound — the dashboard URL (http://127.0.0.1:7420/#token=…, same fragment flow as the browser) cannot 404 on first paint. Closing the window hides it (the status-item menu — Open Dashboard / Quit — is the only real exit), and quitting drops the runtime, which cancels the monitor loops (kill_on_dropssh children) and closes live PTY masters (SIGHUP tossh -tt). The shell's static assets are committed:crates/sw-cli/tauri.conf.json(no static window — the window is built at runtime with the token-bearing URL;frontendDistpoints at the committedgui-stub/placeholder),crates/sw-cli/icons/(RGBA PNGs;generate_context!reads them at compile time), andbuild.rscallstauri_build::build(). Thesw-mcpbinary lives in the same crate and links tauri but never initializes it.
Phase plan (summary)
- ssh-config parser → sw-store schema → API end-to-end (
serve,token). - sw-monitor: sync service, probe/metrics scheduler, retention + history.
- sw-ui: Leptos CSR dashboard via trunk.
- Terminal (PTY → WebSocket) and remote exec.
- sw-mcp: stdio MCP server.
- Tauri shell and packaging.
Round 2 (2026-09, complete): config write-back with diff preview and
audit → key management (scan/generate/delete/deploy) → key & audit pages
in the UI → the config/key/audit tools exposed over MCP (16 tools total,
destructive ones dry_run-defaulted) → stale-host purge. Contracts:
003-write-and-audit.md.