Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help


implementation: current-as-of-2026-09-14

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-core depends 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-store is the only crate that talks SQL; repositories speak sw-core types only.
  • sw-ssh is the only crate that spawns ssh or opens sockets.
  • sw-ui is a leaf: nothing in Rust depends on it — its build output (crates/sw-ui/dist) is the artifact, embedded by sw-api.

Run modes

The single sw binary exposes:

ModeWhat runsPhase
sw serveaxum API + embedded dashboard + the sw-monitor loops (sync, scheduler, retention)1–2
sw uithe desktop shell (default when no subcommand is given): everything serve runs, plus a Tauri webview window and a status-item menu6
sw mcpstdio JSON-RPC MCP server over the same SQLite file5
sw sync [--dry-run]one-shot ~/.ssh/config sync into the store2
sw tokenprint a generated bearer token1
sw netcheck whether THIS machine can reach the network—
sw backup / sw restoresnapshot the store and config into one archive, and put it back—

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. Alerting — what the collected metrics are finally for — has its own contracts in 004-alerting.md, and the BYOK assistant, which turns the same data into answers and answers into proposed commands, in 005-assistant.md.

Three cross-cutting invariants are architectural, not per-feature:

  • One editor — all config writes go through sw-core's pure edit_config (String → String, no fs, wasm-compatible); sw-monitor's ConfigWriter owns the file lifecycle: read → edit → rolling config.sw.bak backup → tmp(0600)+rename atomic write, all under a cross-process File::lock on <config>.sw.lock so sw serve and sw mcp cannot interleave read-modify-write cycles.
  • Every mutation is audited — config edits, key operations, host purges, alert and assistant settings, and every command a fan-out runs insert a row tagged with the caller's source (api/mcp); the dashboard's /audit page is a plain reader over it.
  • The catalog holds servers only — ~/.ssh/config entries that exist to pick an identity for a git remote (Host github-as-…) are filtered at the single sync read path, so the API, the CLI and the MCP server import the same fleet. Rows imported before the rule are deleted rather than flagged stale: an authentication alias is not a server that went away.

Localhost trust model

Two layers, no user accounts:

  1. Bind guard — sw serve binds loopback only and refuses any other bind target. The API is local by construction.
  2. Bearer token — every non-asset request must present the token sw token prints (also sw 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, the append-only audit_log (migration 0003) that records every config/key/purge mutation with its source (api / mcp), the alerting trio (migration 0004): thresholds, live alert state and transition history; and the assistant's own settings (0008/0009), which hold the operator's LLM credentials. 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.

Backups are taken through SQLite, not the filesystem: sw backup asks the store for a VACUUM INTO snapshot, so a copy taken while the server is running is complete rather than a data.db whose recent writes are still in a -wal sidecar. The archive also carries config.toml — the API token and the assistant key live there — so it is written 0600. sw restore moves the files it replaces aside instead of deleting them, and refuses an archive without a matching manifest.

Frontend and desktop shell

  • UI — Leptos CSR built by trunk 0.21.14, pinned. Trunk 0.22 is 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 in crates/sw-ui/dist, embedded by sw-api with rust-embed (mime-guess on for content types). The dist output is committed (CI never runs trunk): rebuild with trunk build --release in crates/sw-ui, commit the hashed assets, and rebuild so rust-embed picks them up. The wasm entry is declared in index.html as <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 a tokio::sync::broadcast channel created in sw-cli and shared with the API state; sw-api forwards them to subscribers of /api/v1/events as 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-monitor holds only an Option<Sender> — it never depends on sw-api, and callers that take no channel (MCP, tests) are unaffected.
  • Terminal — the in-browser terminal attaches to a persistent tmux session on the remote host (tmux new-session -A -s sw), guarded by a command -v tmux check so hosts without it still get a login shell. A build survives a closed tab, and reopening the terminal walks back into the same session rather than starting beside it.
  • Desktop shell — sw ui runs 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 why main in sw-cli is a plain fn, not #[tokio::main]), while the store, monitor scheduler and axum run on a dedicated tokio runtime created in src/ui.rs. Startup is ordered so a tauri-plugin-single-instance duplicate exits inside plugin init (never touching the shared port) and setup only 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_drop ssh children) and closes live PTY masters (SIGHUP to ssh -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; frontendDist points at the committed gui-stub/ placeholder), crates/sw-cli/icons/ (RGBA PNGs; generate_context! reads them at compile time), and build.rs calls tauri_build::build(). The sw-mcp binary lives in the same crate and links tauri but never initializes it.

Phase plan (summary)

  1. ssh-config parser → sw-store schema → API end-to-end (serve, token).
  2. sw-monitor: sync service, probe/metrics scheduler, retention + history.
  3. sw-ui: Leptos CSR dashboard via trunk.
  4. Terminal (PTY → WebSocket) and remote exec.
  5. sw-mcp: stdio MCP server.
  6. 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 → stale-host purge → alerting on host-down and resource thresholds, with a connectivity gate → a BYOK assistant beside the terminal and on the dashboard → 22 MCP tools, destructive ones dry_run-defaulted. Contracts: 003-write-and-audit.md, 004-alerting.md, 005-assistant.md.