Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
173 lines
11 KiB
Markdown
173 lines
11 KiB
Markdown
# Ham Lab — MVP plan
|
||
|
||
Proxmox-style web console for a bare-metal Debian 13 host. First target: `core`
|
||
(10.0.0.234). MVP is **service + shell + persistent terminals**; everything else is a
|
||
stub tab or a roadmap item.
|
||
|
||
## Decisions
|
||
|
||
| Area | Decision | Why |
|
||
|---|---|---|
|
||
| Backend | **Go 1.24**, single static binary, frontend embedded via `embed` | `core` has ~370 MiB free RAM; a Go daemon idles at ~15 MiB vs ~80+ for Node. One file to deploy, no nvm path in the unit. Same binary later installs on `grid` unchanged. |
|
||
| PTY | `creack/pty` — server-owned PTYs running `core`'s login shell | Sessions live in the daemon, not the browser, so refresh/tab-switch reattaches. No tmux dependency (not installed on `core`). |
|
||
| Transport | One WebSocket per attached terminal; REST for everything else | Standard; NPM passes WebSockets with one toggle. |
|
||
| Frontend | **React + TypeScript + Vite**, `xterm.js` (+ fit, webgl, web-links addons) | Matches `calorie-counter` / `wedding-website`, so no new toolchain to learn. |
|
||
| Auth | Single-user **password login**, argon2id hash, HttpOnly/Secure/SameSite=Strict session cookie, login rate-limit, Origin check on WS upgrade | This is a root-capable shell on the public internet. Auth is MVP, not roadmap. |
|
||
| Service | `hamlab.service`, `User=core`, `Restart=always`, binary at `/usr/local/bin/hamlab` | Same pattern as `calorie-counter.service`. Runs as `core` so `sudo` works in the terminal without a password, as on the SSH path. |
|
||
| Listen | `10.0.0.234:8100` (free) | NPM is a container, so loopback-only won't do. Firewalling to NPM is a later hardening step. |
|
||
| Data | `/home/core/apps/hamlab/` — `config.yaml`, `state.db` (SQLite) | `apps/` is the nightly rsync target to `grid`, so it's backed up for free. |
|
||
| Ingress | NPM proxy host `hamlab.bigham.dev` → `:8100`, LE cert, WebSockets on, caching off, force-SSL + HSTS + HTTP/2 set in a **second** API call (NPM drops them on create-with-new-cert) | `*.bigham.dev` already resolves; the proxy host alone makes it live. |
|
||
| Repo | Gitea `mitchell/hamlab`, private, `main` | Created 2026-09-17. |
|
||
|
||
## Layout
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ header: Ham Lab · core · load 0.42 · ● connected · [logout] │
|
||
├──────────────┬───────────────────────────────────────────────┤
|
||
│ ▾ Terminal │ │
|
||
│ shell 1 ● │ │
|
||
│ backups │ main content (xterm) │
|
||
│ + New │ │
|
||
│ ▸ Services │ │
|
||
│ ▸ Cron │ │
|
||
│ ▸ ZFS │ │
|
||
│ ▸ Dashboard │ │
|
||
├──────────────┴───────────────────────────────────────────────┤
|
||
│ footer: v0.1.0 · up 12d · hamlab.bigham.dev │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
- Left rail: collapsible groups, one expanded at a time. Active leaf highlighted.
|
||
- Terminal group: one leaf per session, `● ` when a process is running, "+ New
|
||
Terminal" pinned at the bottom. Right-click / long-press → rename, close.
|
||
- Non-MVP groups render as disabled leaves with "coming soon" — the rail shape is
|
||
the product; stubs keep it honest about scope.
|
||
- **Mobile**: rail becomes a slide-in drawer; xterm fits the viewport; a sticky key
|
||
bar above the keyboard (`Esc Tab Ctrl Alt ↑ ↓ ← → |`) because phone keyboards
|
||
have none of those. This is what makes it usable as a PWA rather than a novelty.
|
||
|
||
## Terminal semantics
|
||
|
||
- A session is a PTY + shell owned by the daemon, with an ID, name, cwd hint, and a
|
||
**scrollback ring buffer** (256 KiB). Attaching replays the buffer, then streams.
|
||
- Any number of browser tabs can attach to the same session (all get output; all can
|
||
type). Resize follows the most recent attacher.
|
||
- Switching tabs in the UI just detaches/attaches; the shell keeps running.
|
||
- Closing a session sends `SIGHUP` to the process group and drops it. Shell exit
|
||
closes the session automatically (leaf shows "exited" until dismissed).
|
||
- Session names persist in `state.db` so the sidebar order survives a service
|
||
restart. **The shells themselves do not survive a daemon restart** in the MVP —
|
||
see roadmap (tmux backing).
|
||
- Idle sessions cost one shell process each; no automatic reaping in MVP.
|
||
|
||
## API (MVP)
|
||
|
||
```
|
||
POST /api/login {password} → 204 + cookie
|
||
POST /api/logout → 204
|
||
GET /api/me → {host, user, version, uptime, load}
|
||
GET /api/terminals → [{id, name, running, created}]
|
||
POST /api/terminals {name?} → {id, ...}
|
||
PATCH /api/terminals/:id {name} → 204
|
||
DELETE /api/terminals/:id → 204
|
||
WS /api/terminals/:id/attach binary frames = raw bytes; JSON text frames = {resize}
|
||
GET /healthz → 200 (unauthenticated, for NPM)
|
||
```
|
||
|
||
Everything under `/api` except `login` and `/healthz` requires the session cookie.
|
||
|
||
## Project layout
|
||
|
||
```
|
||
hamlab/
|
||
cmd/hamlab/main.go serve | passwd | version
|
||
internal/
|
||
auth/ argon2id, sessions, rate limit
|
||
term/ PTY manager, ring buffer, attach/detach
|
||
api/ chi router, handlers, WS
|
||
state/ SQLite (modernc, no cgo)
|
||
web/ go:embed of web/dist
|
||
web/ Vite + React + TS + xterm.js, PWA manifest + SW
|
||
deploy/
|
||
hamlab.service
|
||
install.sh build → /usr/local/bin/hamlab → systemctl restart
|
||
Makefile build / dev / deploy / lint / test
|
||
```
|
||
|
||
## Milestones
|
||
|
||
**M0 — skeleton** (service boots, login works)
|
||
- Go module, chi, config loading, `hamlab passwd`, argon2id login, session cookie.
|
||
- `hamlab.service` on `core`, `:8100`, serves a placeholder page.
|
||
- `make deploy` from `core` itself.
|
||
|
||
**M1 — terminals** (the MVP feature)
|
||
- PTY manager with ring buffer, create/list/rename/close, WS attach with resize.
|
||
- React shell: header, rail, footer, Terminal group, xterm main pane.
|
||
- Persistence of session list across refresh and rail switching.
|
||
|
||
**M2 — polish + PWA + ingress**
|
||
- Mobile drawer, key bar, fit-on-rotate.
|
||
- `manifest.webmanifest`, icons (Ham logo on black, same recipe as HamTV), service
|
||
worker caching the app shell only (never `/api`).
|
||
- NPM proxy host for `hamlab.bigham.dev`; verify WS through NPM; force-SSL/HSTS/HTTP2.
|
||
- Stub groups for Services / Cron / ZFS / Dashboard.
|
||
|
||
**Done when:** from the phone, open the PWA, log in, open two terminals, run
|
||
something long in one, switch away and back, refresh — it's all still there.
|
||
|
||
## Roadmap (not MVP — notes only)
|
||
|
||
- **tmux-backed sessions** so shells survive a daemon restart / redeploy. Would spawn
|
||
`tmux new -A -s hamlab-<id>`; needs `mouse on` and status-bar handling in xterm.
|
||
- **Services tab** — `systemctl list-units`, per-unit page: status, last 200 journal
|
||
lines (live-tail via WS), start/stop/restart/enable buttons, unit file viewer.
|
||
- **Cron tab** — **shipped 2026-09-17** (`internal/cron`, `Cron.tsx`): one list built
|
||
from `/etc/crontab`, `/etc/cron.d/*`, every user crontab (via sudo), the scripts in
|
||
the `run-parts` directories those drive, plus systemd timers. Next run computed by an
|
||
in-house cron-expression evaluator (tested: ranges, steps, names, dom-OR-dow rule,
|
||
`@` aliases); last run from the journal's `(user) CMD (command)` lines (cron logs via
|
||
syslog to journald; reading needs sudo/adm). Commented-out jobs show as disabled;
|
||
job-shaped lines inside comment blocks (Debian's header examples) are ignored. Per
|
||
job: Run now (background, as the job's user, `sh -c` with crontab `%` rules, output
|
||
captured), Enable/Disable (comments the line in place, refuses if the file changed
|
||
underneath), Edit source (textarea; validated line-by-line; user crontabs installed
|
||
with `crontab -`, system files written via `sudo tee`), Cron log, and Output file
|
||
when the command redirects to one. Timers: Run now (starts the activated service),
|
||
start/stop/enable/disable, live logs of the service via the Services tab's tail.
|
||
- **ZFS tab** — pools, datasets (`used/avail/refer/usedbysnapshots`), snapshot list,
|
||
scrub status, and the `freeing` counter. `grid` is the target, which implies
|
||
multi-host.
|
||
- **Dashboard** — **first cut shipped 2026-09-17** (`internal/metrics`, `Dashboard.tsx`):
|
||
CPU (total + per core), memory/swap, load, uptime, hwmon temps and fans read straight
|
||
from `/sys` (no lm-sensors), per-interface network rates, per-disk read/write/busy
|
||
from `/proc/diskstats`, filesystem usage, UPS via `sudo -n pwrstat -status`. Sampled
|
||
every 2 s into an in-memory ring of 10 min; the UI seeds from
|
||
`GET /api/metrics/history` then polls `GET /api/metrics`. **Customizable**: a
|
||
Customize mode reorders and hides modules (CPU, Memory, Load, Uptime, Temperatures,
|
||
Fans, Network, Disks, Filesystems, UPS) and hides individual rows (an interface, a
|
||
mount, a disk, a sensor); the layout is stored server-side in `state.json` via
|
||
`GET/PUT /api/prefs/dashboard` so every device sees the same dashboard. The update
|
||
interval (100 ms – 2 s) is part of the layout and drives both the browser's polling and
|
||
the daemon's sampling clock; the chart window is always 300 samples, so 30 s at 100 ms
|
||
and 10 min at 2 s. Still to do: persistent
|
||
history (SQLite ring, e.g. 10 s × 7 days), SMART (`smartctl -j`), longer time ranges.
|
||
- **Multi-host** — same binary as a headless *agent* on `grid` / Mac mini /
|
||
`wyse-5070-1`, `core`'s instance is the UI and fans out over mTLS. The rail grows a
|
||
host level at the top, exactly like Proxmox's datacenter → node tree. Design the
|
||
MVP URL space so `host` can be inserted later without breaking clients.
|
||
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session
|
||
list + revoke, optional NPM access list restricting to WireGuard/LAN.
|
||
- **Files** — browse/upload/download/edit under `/`, with a sudo toggle.
|
||
- **Docker read-only view** — `docker ps`/logs; dockhand stays the source of truth.
|
||
- **Notifications** — push on service failure / SMART warning / UPS event.
|
||
- **Terminal extras** — search, copy-mode, font/theme, split panes, upload via
|
||
drag-drop into cwd.
|
||
|
||
## Open questions
|
||
|
||
- Idle-session reaping policy (never / after N days) — leaning never; it's a
|
||
single-user box.
|
||
- Whether `sudo -i`-style root sessions get their own leaf colour. Cosmetic.
|