Companion to
SPEC.md. Nine milestones from "empty repo" to "v0.1 released." Each milestone is small enough to ship in 1–3 focused sessions and ends in a runnable, demoable state.
Conventions
- ✅ = acceptance criterion (must be true to call the milestone done).
- 🧪 = test added in this milestone.
- 📝 = doc added/updated.
- Every milestone ends with a tagged commit (
m1-foundation,m2-auth, …).
- Errors: all API responses follow
{ "error": { "code": "...", "message": "...", "detail": {...}? } }on non-2xx. Codes are stable strings (e.g.auth.invalid_credentials). - Logging: zerolog JSON to stderr; one log line per request via middleware (
method path status duration_ms user_id). Privileged actions also log toaudit_logtable. - Validation: zod schemas in
web/src/lib/schemas/mirror Go struct tags; backend validates on entry, frontend validates before submit. - Tests: Go unit tests for every package with logic; integration test in
internal/api/router_test.goboots the server in-process. Vitest for frontend lib code; Playwright smoke test added in M9. - CI: golangci-lint,
go test ./...,eslint,vitest run, build the Docker image. Required for merge.
Goal: repo skeleton boots a single binary that serves a placeholder SPA over HTTPS.
- Init
go.mod(github.com/<owner>/controlroom, Go 1.23). - Scaffold folder tree per
SPEC.md§4. cmd/controlroom/main.go: env+flag parsing, signal handling, graceful shutdown.internal/config/: typed config struct, defaults, validation.internal/api/router.go: Fiber v3 app with logger + recover + request-id middleware.internal/tls/: self-signed cert generator (writes to$DATA_DIR/tls/); ACME and proxy modes stubbed with TODO panics so they're explicit.embed.FSmount ofweb/distat/. SPA fallback: any non-/api//wsroute returnsindex.html.- Endpoints:
GET /api/healthz,GET /api/version(build info via-ldflags). - Vite + React + TS + Tailwind + shadcn/ui scaffold in
web/. AddButton,Card,Inputshadcn components. Dark theme as default. - Splash page: logo + "ControlRoom — connecting…" with one fetch to
/api/version. Makefile:make dev(concurrent vite+go-run with hot reload viaair),make build,make image,make lint,make test.deploy/Dockerfile: 3 stages — node-build → go-build (CGO_ENABLED=0) →gcr.io/distroless/static-debian12:nonroot..github/workflows/ci.yml: lint, test, build image (no push).README.md(minimal — quickstart + status badge).
- ✅
make image && docker run -p 8443:8443 controlroom:dev→ splash page over self-signed HTTPS athttps://localhost:8443. - ✅
curl -sk https://localhost:8443/api/healthz→200 {"status":"ok"}. - ✅ Image size ≤ 30 MB; idle RSS ≤ 50 MB (
docker stats). - ✅ CI green on a PR.
- 🧪
internal/config/config_test.go: defaults + invalid combos. - 📝
README.mdwith quickstart.
Tag: m1-foundation.
Goal: secure, single-admin auth working end-to-end. Fresh install → browser wizard → login → authenticated dashboard placeholder.
internal/store/: open SQLite viamodernc.org/sqlite, hand-rolled migrations table + numbered SQL files ininternal/store/migrations/.- Migrations 001:
users(id, username UNIQUE, password_hash, totp_secret NULL, totp_enabled BOOL, role DEFAULT 'admin', created_at, updated_at)sessions(id, user_id FK, refresh_hash, family_id, parent_id NULL FK, ip, user_agent, created_at, expires_at, revoked_at NULL)audit_log(id, ts, user_id NULL, ip, action, target NULL, outcome, detail JSON)setup_token(token_hash, created_at, used_at NULL)— single row, generated on empty users table.
internal/auth/password.go: bcrypt(cost=12) wrap, constant-time compare on verify.internal/auth/totp.go: enroll (generate secret + QR PNG bytes), verify (RFC 6238, ±1 step window).internal/auth/jwt.go: HS256 access (15m) + refresh (7d), key from$DATA_DIR/jwt.key(rotated on bootstrap if missing).internal/auth/sessions.go: rotating refresh with family tracking — reuse of a revoked refresh in a family revokes the whole family + writes critical audit event.- Middleware:
middleware/auth.go— required-auth wrapper; reads access cookie, attaches*Userto ctx.middleware/csrf.go— double-submit token check on POST/PATCH/DELETE.middleware/ratelimit.go— token bucket per IP;/api/auth/login5/min, exponential backoff per username (in-memory map; persists across restarts not required for v0.1).middleware/audit.go— wraps state-changing handlers, writes audit row.
- Handlers:
setup:GET /status,POST /verify-token(returns short-lived setup JWT in cookie),POST /complete(creates admin, optionally TOTP, marks token used).auth:login,logout,refresh,me,2fa/enroll,2fa/verify,2fa/disable.
- Bootstrap: on server start, if
usersempty, generate one-time setup token (32 random bytes hex), insert hash, log it once at INFO with==== ControlRoom setup token: <token> ====. - Frontend:
lib/api.tstyped fetch (auto-refresh on 401, CSRF header injection).lib/auth.tsTanStack Query hooks:useMe,useLogin,useLogout.- Routes:
/setup(3-step wizard with shadcnStepper+Form+OTPInput),/login,/(placeholder authenticated landing). - Top-level guard: route to
/setupifGET /api/setup/statusreturnsrequired: true; route to/loginon 401; else render app. - Strong password meter (zxcvbn-ts, lazy-loaded).
- ✅ Fresh container boots → token printed once → browser at
https://host:8443→ auto-redirect to/setup→ token check → username + password → optional TOTP enrollment → done → redirected to/login→ log in → see authenticated placeholder. - ✅ Reusing a refresh token revokes its family; client is forced back to login.
- ✅ Wrong password 5× from same IP → 429 for 60s.
- ✅
/api/auth/mereflects active TOTP state. - ✅
/setupreturns 410 once admin exists. - 🧪
auth/password_test.go,auth/totp_test.go,auth/sessions_test.go(rotation + reuse),api/auth_test.go(full login flow),api/setup_test.go. - 📝
docs/SECURITY.mdfirst draft (threat model + auth model).
Tag: m2-auth.
Goal: authenticated user sees a live, mobile-friendly dashboard with real metrics.
internal/collectors/:cpu.go—/proc/statsampling at 1Hz with delta (per-core %).mem.go—/proc/meminfo(total/used/free/cached/buffers/swap).disks.go— parse/proc/mounts, statfs each, exclude pseudo-fs; opportunistic SMART summary viasmartctl -jif installed.net.go—/proc/net/dev(RX/TX counters → rates).temps.go—sysfs hwmonwalk; ifvcgencmdexists (RPi), includemeasure_temp.host.go— uname, distro from/etc/os-release, kernel, uptime, load.- All collectors implement
Snapshot() (T, error); a top-levelAggregatorruns them concurrently.
- Endpoints:
GET /api/system/overview,WS /ws/system/stats(1Hz, JSON-encoded snapshots, ping/pong every 30s). - SPA shell:
components/AppLayout.tsx: sidebar (Dashboard / Services / Containers / Terminal / Logs / Network / Settings), top bar (host name, status pill), mobile drawer toggle.- Theme toggle (light/dark/system) persisted in localStorage; CSS vars driven.
- Polished empty/loading/error states.
- Dashboard tiles (responsive 2-col on mobile, 4-col desktop):
- CPU (overall % + per-core mini bars, model + freq).
- Memory (used/total + swap, mini chart).
- Disks (per-mount bar, SMART pill if present).
- Temperature (largest reading + sensor list popover).
- Uptime + Load.
- Network (RX/TX rate per interface).
- Live updates via
useWebSockethook pushing into TanStack Query cache.
- ✅ Dashboard updates every 1s on desktop and mobile (Chrome DevTools throttled to "Slow 4G" still works).
- ✅ Lighthouse mobile score ≥ 90 on the dashboard.
- ✅ WS reconnects within 5s after network blip; falls back to REST polling at 5s if WS fails.
- ✅ No layout shift on metric updates (reserve dimensions).
- 🧪
collectors/*_test.gowith fixture/procfiles intestdata/. - 🧪
api/system_test.gosnapshot test of the overview shape.
Tag: m3-dashboard.
Goal: list and control systemd units; live logs.
internal/systemd/— interfaceSystemDwith implementationsdbus(real) andfake(for tests).List(filter),Get(unit),Start/Stop/Restart/Enable/Disable(unit).- Unit name validation regex (
^[a-zA-Z0-9@_.\-:\\]+\.(service|socket|target|timer|path|mount)$).
- Logs:
WS /ws/services/:unit/logsshells out tojournalctl -fu <unit> --output=short-iso -n 200and streams; cancellation kills the child process. - Endpoints per SPEC §6.
- SPA
/servicespage:- Sortable, searchable virtualized table (TanStack Table + Virtual). Columns: Name, Description, Load, Active, Sub, Memory, Tasks.
- Inline action menu (start/stop/restart/enable/disable with confirmation modals where destructive).
- Slide-over (
Sheet) on row click: details + last-N journal + live tail toggle. - Status pills: active=green, inactive=gray, failed=red, activating/deactivating=yellow.
- Audit each action.
- ✅ List of ≥ all
*.serviceon a fresh Ubuntu, sorts/searches without lag at 500 units. - ✅ Start/stop/restart works; UI updates within 1s of action.
- ✅ Live log tail keeps up with a chatty unit (
journalctl -ftest). - ✅ Invalid unit name from URL → 400, never executed.
- 🧪
systemd/dbus_test.go(skipped in CI without dbus);systemd/fake_test.go;api/services_test.gousing fake.
Tag: m4-services.
Goal: parity with services, but for Docker/Podman containers.
internal/docker/— wrapper arounddocker/docker/client. Auto-detect socket; if missing, module disables itself and/api/containersreturns 503 withcode: "containers.unavailable"(SPA hides nav entry).- Endpoints per SPEC §6 (list, inspect, start/stop/restart, delete, WS logs, WS stats).
- Stats: subscribe to docker stats stream; downsample to 1Hz before WS forward.
- SPA
/containers:- Toggle: grid (cards) ↔ table.
- Group by
com.docker.compose.projectlabel with collapsible headers. - Card shows: image, status, port mappings, CPU%, MEM, uptime.
- Slide-over: full inspect, env vars (masked toggle), mounts, live logs, live stats sparklines.
- Action confirmations; "force" checkbox on delete.
- Audit each action.
- ✅ Works against Docker Desktop, Docker Engine, and rootless Podman with the docker compat socket.
- ✅ Stats stream stays under 5% CPU when watching 30 containers.
- ✅ Removing the docker socket and reloading hides the page cleanly (no 500s).
- 🧪
docker/client_test.goagainst ephemeral docker viadockertest;api/containers_test.gowith a fake.
Tag: m5-containers.
Goal: secure, fast, full PTY in the browser.
internal/pty/session manager:New(rows, cols, shell, env)→ spawns shell withcreack/pty, returns session.- Bidirectional
io.Copywith backpressure-aware buffering (32 KB). Resize,Close(sends SIGHUP, waits, then SIGKILL after 5s).- Configurable allowed shells (
/bin/bash,/bin/sh,/usr/bin/zsh); unknown rejected.
WS /ws/terminal:- First client frame: JSON
{rows, cols, shell?}. - Subsequent: binary frames raw to PTY; control frames JSON
{type:"resize", rows, cols}. - Server → client: binary frames raw from PTY.
- Audit start (
session_id, shell) and end (exit_code, duration_ms, bytes_in, bytes_out); no keystroke recording.
- First client frame: JSON
- SPA
/terminal:- xterm.js + FitAddon + WebLinksAddon + ClipboardAddon.
- Theme matches app palette.
- Reconnect button on disconnect (creates fresh session).
- Mobile: virtual keyboard tap to focus; ctrl+alt soft-buttons row.
- Idle timeout: server closes session after 30 min idle (configurable).
- ✅ Open terminal → run
top→ resize browser → output reflows immediately. - ✅ Closing browser tab terminates the shell within 5s (verified with
pgrep). - ✅ Pasting 10 KB block doesn't desync.
- ✅ Audit log shows session start + end with byte counts.
- 🧪
pty/manager_test.go,api/terminal_test.go(golang test driving WS withnhooyr/websocket).
Tag: m6-terminal.
Goal: check & apply system updates with live progress.
internal/jobs/— generic background job runner (concurrent jobs by id; ring-buffer per-job stdout/stderr; final outcome persisted).internal/updates/apt.go:Upgradable()parsesapt list --upgradable(sets localeLC_ALL=C).Update()runssudo apt-get update.Upgrade()runssudo apt-get -y -o Dpkg::Options::="--force-confold" upgrade.
- Endpoints per SPEC §6: list, check, apply, jobs/:id, WS jobs/:id.
- SPA
/updates:- Package table (name, current → new, source, size, security pill).
- "Check for updates" button → starts job, opens progress drawer.
- "Install updates" → modal listing packages + estimated size → confirm → progress drawer with live tail.
- Reboot-required banner if
/var/run/reboot-requiredexists; one-click reboot (with explicit confirm input "REBOOT").
- ✅ End-to-end on a Debian/Ubuntu VM with held-back packages and security updates.
- ✅ Job survives client disconnect; reconnecting shows full output to-date.
- ✅ Two simultaneous
applycalls → second returns 409. - 🧪
updates/apt_test.goparses fixture outputs ofapt list --upgradable.
Tag: m7-updates.
Goal: view/edit interfaces and firewall; browse and tail journald.
Networking
internal/network/iface.go: enumerate viavishvananda/netlink; live RX/TX deltas.internal/network/netplan.go: read/etc/netplan/*.yaml, present current config;PATCHwrites a new file90-controlroom.yaml, runsnetplan try(10s revert) thennetplan applyif confirmed in-UI.internal/network/ufw.go: parseufw status numbered; add/delete/enable/disable.- SPA
/network:- Interfaces card (per iface: name, state, MAC, IPs, RX/TX rate, MTU).
- Edit slide-over: DHCP / static (IP/CIDR, gateway, DNS) with
netplan trywarning. - Firewall card: status toggle, rules table with add-rule form.
Logs
5. internal/logs/journal.go: shell out to journalctl --output=json for both static (with -S/-U/-p/-u/-n) and live tail (-f).
6. SPA /logs:
- Filter bar (unit autocomplete, priority dropdown, since picker, free-text search).
- Virtualized list with priority color-coding.
- "Live tail" toggle → switches to WS.
- Each row expandable for full JSON.
- ✅ Switch interface from DHCP→static and back without losing the SSH session that's serving ControlRoom (via
netplan tryrevert). - ✅ Add and remove a UFW rule; rule list refreshes immediately.
- ✅ Logs page handles 10k lines in the virtualized list smoothly.
- ✅ Filtering by unit + priority + search composes correctly.
- 🧪
network/netplan_test.go(parse + emit YAML),network/ufw_test.go(parseufw status numberedfixtures),logs/journal_test.go(parse JSON output).
Tag: m8-net-logs.
Goal: v0.1 ships. Anyone can install it on a fresh Ubuntu Server in under 5 minutes.
- Mobile QA — every page used one-handed on a phone; test on iOS Safari + Android Chrome.
- Accessibility — axe-core via Playwright; keyboard nav full coverage; aria-live for status changes; contrast ≥ AA.
- Public-bind warning — banner if server bound to non-loopback non-RFC1918 address.
- Settings page finalized:
- Account: change password, manage TOTP.
- Server: host display name, telemetry opt-in toggle, TLS info, log level.
- About: version, commit, runtime info.
deploy/install.sh(bare-metal):- Detects Debian/Ubuntu; aborts elsewhere with a clear message.
- Creates
controlroomuser,$DATA_DIR. - Downloads release binary (or builds locally if
--from-source). - Writes
/etc/systemd/system/controlroom.servicewith capability +ProtectSystem=strict. - Writes
/etc/sudoers.d/controlroom(validated withvisudo -c). - Generates self-signed cert; prints first-run token.
--uninstallreverses cleanly.
deploy/docker-compose.ymlwith sensible defaults + comments.- Docs:
README.md— hero, screenshots, quickstart (Docker + bare-metal), feature list, link to SPEC.docs/INSTALL.md— both paths in detail.docs/SECURITY.md— finalize threat model + reporting.docs/API.md— generated from handler annotations.
- Release workflow —
goreleaserconfig builds linux/amd64 + linux/arm64 binaries + multi-arch Docker image; pushes on git tag. - Smoke test — Playwright run on a real Ubuntu Server VM hitting the major flows.
- ✅ Fresh Ubuntu 24.04 Server VM →
curl -fsSL <install-url> | sudo bash→ wizard → dashboard → start/stop a service → live terminal →apt upgrade→ done. Total time: target ≤ 5 minutes. - ✅ Idle RSS ≤ 50 MB after 1 hour with 5 dashboard clients connected.
- ✅ Image size ≤ 25 MB.
- ✅ axe-core: 0 critical, 0 serious.
- ✅ All public-facing docs cross-link correctly.
- 🧪 Playwright smoke spec covers: setup wizard, login, dashboard live-update, start/stop a service, open terminal and run
echo hi, list containers (or empty-state if none).
Tag: v0.1.0.
Not scheduled; sized roughly so we can prioritize later.
- RBAC — promote
users.roleto enforced; add user CRUD UI + endpoints. (~M2-sized) - Backups — restic integration (snapshots, schedules, restore). (~M-large)
- Compose editor — visual + raw editor for
docker-compose.yml, validate + apply. (~M-large) - Sensors graphing — historical metrics in a small TSDB (Prometheus remote_write or local rrd-style). (~M-large)
- Custom scripts — admin-defined buttons that run a vetted command with a confirmation. (~M-medium)
- Notifications — Discord/Telegram/Webhook on selected audit events (failed logins, service crash, update available). (~M-medium)
- Multi-host federation — register peer ControlRoom instances; aggregated dashboard. (~M-extra-large; arguably its own product.)
- Distro expansion —
dnfandpacmanbackends behind the existingpkginterface. (~M-medium each)