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: complete

Write Path and Audit Contracts

Contracts for everything that mutates state beyond the read-only fleet view: ssh-config editing, SSH key management, the audit log they report into, and the stale-host purge. Everything here is live and verified against the shipped binary (2026-09).

Config editing

~/.ssh/config stays the source of truth; the app edits it in place under strict preservation rules.

  • Stanza splicing, not regeneration — sw-core's edit_config is a pure String → String transform that replaces only the lines of the target Host stanza. Comments between blocks, indentation, directive casing, unknown directives (ProxyJump, …) and the raw ~ in IdentityFile paths survive byte-identical. Regenerating the file was rejected precisely because the parser is lossy in the reverse direction.
  • Diff preview → confirm → apply — every write endpoint takes dry_run; a dry run returns the unified diff without touching the file. The UI renders the same diff before its apply button.
  • Optimistic concurrency — updates may carry expected_current (the stanza as the caller last saw it). If the stanza changed underneath, the apply answers 409 instead of overwriting. This is stanza-scoped on purpose: an unrelated edit elsewhere in the file does not block yours.
  • Cross-process locking — the read → edit → backup → write cycle runs under an exclusive std::fs::File::lock on <config>.sw.lock (0600), so the HTTP server and a concurrent sw mcp process cannot interleave their cycles. In-process, routes additionally serialize on a tokio mutex in the API state. Locking is fail-closed: no lock, no write.
  • Atomic write, rolling backup — the new text lands via tmp file (0600) + rename; the previous content is kept as the single rolling ~/.ssh/config.sw.bak (overwritten each apply, never multiplied). The audit log's unified diffs are the history; the backup is only the one-step undo.
  • Rejections — multi-pattern Host lines, aliases containing wildcards or whitespace, duplicate adds, updates/deletes of unknown stanzas, and CRLF files are refused outright. Include/Match present in the file produce warnings, not failures.
  • EOF normalization — add always terminates the file with exactly one trailing newline; deleting the last stanza also removes the separator blank line ahead of it, so an add → delete round trip is byte-identical (regression-tested).

Endpoint: POST /api/v1/ssh-config/hosts with {dry_run, op: add|update|delete, stanza?, expected_current?, alias?}. MCP: sw_edit_ssh_config (same payload, dry_run defaults to true).

Key management

  • Scan — ~/.ssh and ~/CERT (configurable [keys] directories), recursive to depth 3. A file is a key when it has a .pub sibling OR the config references it as an IdentityFile (path-expanded compare — this is what picks up cloud .pem files). .pub files themselves, authorized_keys, known_hosts* and config* are excluded. Metadata only: type/bits/fingerprint (via ssh-keygen -lf), size, mtime, referencing hosts. Private key material never leaves disk.
  • Generate — name validated ([A-Za-z0-9._-], no leading dot, no ..), refuses to overwrite; spawns ssh-keygen -t … -N "".
  • Delete — usage analysis first; if the key is referenced by any host config, the call returns 409 listing them unless force=true. Removes private + .pub.
  • Deploy — validates the .pub content (single line, ssh-/ ecdsa-/sk- prefix, no quotes/newlines — injection guard), then one remote command through the standard executor that is idempotent by marker: SW_ALREADY when the key is already in authorized_keys, SW_ADDED when appended. Recorded in exec_log and the audit log.

Endpoints under /api/v1/keys (GET, POST, DELETE ?force=, /{name}/usage, /{name}/deploy). MCP: sw_list_keys, sw_generate_key, sw_get_key_usage, sw_delete_key, sw_deploy_key.

Audit log

Append-only audit_log table (migration 0003): action, target, detail (unified diff or summary, 8 KB cap), source (api | mcp), created_at (RFC3339 UTC). Actions in use: config_add, config_update, config_delete, key_generate, key_delete, key_deploy, host_purge.

House rules:

  • No-ops are not audited — a write that changes nothing (e.g. a purge with zero stale hosts) writes no row and emits no event. The audit log records what changed, not what was attempted.
  • Source is injected at the seam — services carry a with_source() builder defaulting to api; the MCP handlers construct theirs with mcp. A route cannot accidentally log as the wrong caller.

Readers: GET /api/v1/audit?limit= (default 50, max 500), the dashboard /audit page, and sw_list_audit over MCP.

Stale-host purge

Hosts that vanished from ~/.ssh/config are flagged missing_from_config = 1 by sync ("stale") but kept — history is worth more than disk. Purging is a deliberate second decision:

  • Stale only — DELETE /api/v1/hosts/{alias} hard-deletes a host row iff it is stale; an active host answers 409 pointing at the config delete path (remove the stanza, re-sync, then the row follows).
  • Cascade — the four child tables (status checks, metric samples, hourly aggregates, exec log) reference hosts(alias) with ON DELETE CASCADE, and both store connections enable the foreign_keys pragma. One DELETE cleans everything; there is no hand-rolled child cleanup to drift out of sync.
  • Bulk — POST /api/v1/hosts/purge-stale purges every stale host in one audited call (target: "*", detail lists the aliases).
  • Events — purges publish a hosts_purged FleetEvent so live dashboards refresh.
  • UI — the dashboard shows a stale banner (two-step confirm) when stale hosts exist; a stale host's detail page replaces the config-delete danger zone (which would 404 — the stanza is gone) with a "purge from store" zone. MCP mirrors both as sw_delete_host / sw_purge_stale_hosts, dry_run defaulting to true.

MCP tool surface (16 tools)

Read: sw_list_hosts, sw_get_host, sw_get_status, sw_get_metrics, sw_list_exec_history, sw_list_keys, sw_get_key_usage, sw_list_audit · exec: sw_ssh_exec · sync: sw_sync_ssh_config · write (dry_run-defaulted where destructive): sw_edit_ssh_config, sw_generate_key, sw_delete_key, sw_deploy_key, sw_delete_host, sw_purge_stale_hosts.

Errors are domain-mapped: parse/validation → -32602, not-found and conflict → tool errors with recovery hints, the rest → internal. The server is hand-rolled JSON-RPC 2.0 over stdio (no rmcp), sharing the same SQLite file and services as the HTTP API — WAL mode is what makes the two-process access safe.