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-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-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 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 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 and host purges insert an audit_log row tagged with the caller's source (api/mcp); the dashboard's /audit page is a plain reader over it.

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, 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. 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.
  • 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 (16 tools total, destructive ones dry_run-defaulted) → stale-host purge. Contracts: 003-write-and-audit.md.