Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
19 KiB
Bug bash — 2026-09-18
Fix pass 2026-09-18: every item carries its outcome; ✅ resolved and verified live, ➖ no change with reason. Found by a headless Chromium pass (iPhone and 1440px desktop viewports) over every page on the hub and on each remote machine, an offline-node scenario (wyse stopped for the test, then restarted), direct API probing with hostile inputs, and a code read. Ordered by severity within each group.
Security / robustness
- ✅ RESOLVED 2026-09-18 — only regular files are readable; the byte cap applies to what is sent, not to
stat./dev/zero,/dev/nulland FIFOs answer 400 in 30 ms;/proc/cpuinfostill reads. Reading a special file hangs the request forever.GET /api/files/read?path=/dev/zeronever ends: themaxcap compares againststatsize, which is 0 for device files, so the cap never triggers and the handler streams/dev/zerountil the client gives up. Same for FIFOs (blocks on open) and/proc/self/mem. Opening one in the Files viewer would hang the tab. Needs: refuse non-regular files for read/edit, and cap the copy withio.LimitReaderregardless ofstat. - ✅ RESOLVED — per-client buckets (first X-Forwarded-For hop or the peer) with the same 5-strike schedule, plus a global backstop of 100 failures per 5 min → 1 min. Verified: core's public IP locked while grid logged in fine. Login lockout is global, not per client. Five bad passwords from anyone on the
internet lock the real user out for up to 10 minutes (
auth.Limiteris one bucket). The bash locked the hub for the full 10 minutes twice. Also locksPOST /api/tokens, so hub pairing fails during a lockout. Needs per-IP buckets with a modest global cap. - ✅ RESOLVED — server rejects refreshMs outside 100–60000 and non-identifier themes (400); client ignores out-of-range rates from any source. UI prefs from the server are not validated. A stored
refreshMsof-5made the client callsetInterval(-5)on every live view: a request storm that took the browser toERR_INSUFFICIENT_RESOURCESand hammered the daemon.themeis validated only by falling back. Needs range checks on load (and on save server-side). - ✅ RESOLVED — every response carries X-Frame-Options DENY, CSP frame-ancestors none, nosniff, no-referrer. No anti-framing / CSP headers. Only HSTS (from NPM). A root-capable console should
send
X-Frame-Options: DENY,Content-Security-Policy: frame-ancestors 'none',Referrer-Policy: no-referrer,X-Content-Type-Options: nosniffon every response. - ✅ RESOLVED — kernel threads (empty cmdline) and the daemon's own PID are refused. Signals can be sent to kernel threads.
POST /api/procs/2/signal(kthreadd) returns 204. Harmless in practice, but the guard should refuse PIDs with an empty cmdline (kernel threads) and our own PID. - ✅ RESOLVED — unit runs with
SupplementaryGroups=systemd-journal admso journal reads need no sudo, and the installer drops/etc/sudoers.d/hamlab-quiet(no syslog/pam session for the read-only polling commands). Verified: 0 sudo lines in 60 s of 250 ms polling (was 839/10 min). Applied on all four hosts. Journal spam from the daemon's own sudo calls. Every sudo'djournalctl,smartctlorpwrstatwrites threepam_unix(sudo:session)lines into the journal underhamlab.service: 839 lines in 10 minutes at a 250 ms refresh, and the journal is already 4 GB. It also buries Ham Lab's own log entries. Fix options: add the service user tosystemd-journal(andadm) so journal reads don't need sudo, and/or a sudoersDefaults!/usr/bin/journalctl !syslog, !pam_sessionline in the installer. - ✅ RESOLVED — server answers 409 unless
overwrite=1; the UI asks "Replace it?" first. Uploading over an existing file silently overwrites it. No confirmation, no "replace?" prompt. Same for "+ file" when the name exists (server refuses; UI just shows the error, fine) — upload is the gap. - ➖ NO CHANGE — Go's mux redirects the unclean path to
/etc/passwd, which is an ordinary deep link the SPA must serve; there is no API prefix left by the time the static handler sees it. Harmless./api/<anything>/../../xfalls through to the SPA and returnsindex.htmlwith 200 instead of a JSON 404. Harmless, but sloppy for an API prefix.
Functional bugs
- ✅ RESOLVED — 401 carries the server message; the form shows "wrong password" (verified headless). Wrong password gives no feedback at all. The 401 is thrown as
Unauthenticatedwith an empty message, and the login form only renderserrwhen non-empty. The button just re-enables. Reproduced on phone and desktop. - ✅ RESOLVED — hub-only pages bounce to the dashboard on switch (verified headless). Switching machines while on Settings or Machines keeps showing the hub's page.
switchMachineonly resets the view for Machines; Settings stays, so "mac" shows core's account/token/about panels. Both are hub-only pages and should bounce to the dashboard (or be clearly labelled as hub pages). - ✅ RESOLVED — escaped; no console error. Machines add-form
patternis an invalid regex in Chromium.[a-z0-9][a-z0-9-]*fails under thevflag ("Invalid character class"), logs a console error and the browser skips validation. Needs[a-z0-9][a-z0-9\-]*. - ✅ RESOLVED — refresh from the newest repodata under libdnf5/dnf caches (Mac: 9 d), last upgrade from
dnf history(194 d), label reads "from dnf history". Updates tab on dnf hosts shows nonsense. Mac: "Index refreshed 561d ago" (mtime of/var/cache/dnf, which Fedora 41+ no longer uses;libdnf5is the live cache) and "Last upgrade never · from apt history" (label and source are apt-specific). Needsdnf historyfor last upgrade, metadata timestamps for refresh, and neutral labels. - ✅ RESOLVED — journal read incrementally by cursor, timers from one
systemctl show, services' two calls run in parallel. Cron 3.6 s → 0.08 s, services 0.6 s → 0.05 s, with last-run and next-run data intact. Cron overview is slow: 3.6 s per request on core. At any refresh rate the page is constantly busy. The 30-day, 20 000-line journal scan for "last run" is rebuilt every 5 s; timers make threesystemctlcalls. Needs an incremental journal cursor and a singlesystemctlcall. Services list is 0.6 s for the same reason (two calls). - ✅ RESOLVED — heap profile showed a 48 MB
bytes.Bufferfrom bufferingjournalctldumps; those are now streamed line by line, plus a 160 MB soft memory limit for the GC. RSS 22 MB fresh, 27 MB after 25× cron/logs/procs/services (was 180 MB). pprof stays available behind the login at/api/debug/pprof/. Daemon memory grows. RSS on core went 22 → 85 → 216 MB during the day (136 MB at rest after the bash). Suspects: per-pid caches in the process table (procInfoonly prunes on the next walk), task output buffers, SMART cache, xterm scrollback rings that are never freed for exited sessions. Needs a heap profile under load. - ✅ RESOLVED —
--dirtydropped from the version and deploys now follow commits; binaries report e.g.v0.1.0-38-gc1805f6. Version string is always-dirty.make deploybuilds before the commit, so every installed binary reports e.g.v0.1.0-32-ga372378-dirty, and the Machines page compares with-dirtystripped. Build after commit, or tag releases. - ✅ RESOLVED — hash routing
#/<machine|hub>/<view>: reload restores machine and tab, Back/Forward work, links can point at a page on a machine (verified headless). View resets to Dashboard on reload; no deep links; browser Back leaves the app. The selected machine survives a reload (sessionStorage) but the tab does not, and there is no URL routing at all. Needs hash or path routing (/#/grid/services). - ✅ RESOLVED — newest first. Cron log tab lists oldest first and starts at the top. 200 lines from Sep 10 with the newest at the bottom, off-screen. Should show newest first or scroll to the end.
- ✅ RESOLVED — "Nothing matches." Processes: empty filter result shows nothing. No "nothing matches" message.
- ✅ RESOLVED — exited shells remove themselves 10 minutes after exit (verified live: seeded 17:53:56, gone at 18:03:59 with no daemon restart in between). Found and fixed alongside: a daemon restart used to re-spawn exited shells as fresh running ones; only running shells are persisted now. Idle running shells are deliberately kept. Terminals accumulate. Nothing ever reaps exited or idle sessions; the bash left several "bug-bash-term" shells on the hub until deleted by hand. Exited sessions should auto-remove after a while; idle ones at least be counted somewhere.
- ✅ RESOLVED — a node answers 409 "busy" when a package task is running or a terminal is attached; the Machines page offers Force,
hamlab push-nodes --force/make push-nodes-forcedo the same. Verified: 409 with a terminal attached on grid, 200 with force. Self-update restarts without warning running work. A push to a node kills its open terminals and any running task (upgrade in progress). The hub should refuse or warn when the node reports a running task or attached terminals. - ✅ RESOLVED — PathError and LinkError are unwrapped to the bare reason. Error text leaks raw Go errors. e.g. rename shows "rename /tmp/x /tmp/y: no such file or directory". Fine for a lab, ugly for a product; strip the syscall prefix.
Mobile / UX
- ✅ RESOLVED — on coarse pointers every button, chip, icon button, link, header and select is at least 36 px tall (verified: no control under 32 px on the dashboard or processes pages). Tap targets under 28 px on phone: header ⏻ (25), logout (22), customize ▲ ▼ hide
(23), terminal leaf ✎ ✕ (24), chips (25), table headers (27). Apple's guideline is
44 pt. Systemic: the small
.btn/.chip/.icon-btnsizes need a touch variant. - ✅ RESOLVED — the dialog asks for the machine name (
mac); the node still checks its own hostname under the hood. Power dialog on the Mac asks you to type5ce91ee54af6. The confirmation uses the node's hostname, which on the Mac is the unset default. Should offer the machine name (mac) as the confirmation word on remote machines. - ✅ RESOLVED — down interfaces collapse to one line marked "down"; modules with nothing to show (fans, UPS, temps, disks) hide unless customizing. Dashboard shows dead interfaces and empty modules. wlp1s0 (down) gets a full sparkline block of flat lines; "Fans: no fan sensors" is a permanent empty card on hosts without fans. Down interfaces should collapse to one line; empty modules should auto-hide unless customizing.
- ✅ RESOLVED — "load 1.39 1.56 1.55" with a tooltip. Header load average is unlabelled ("1.39 1.56 1.55" floating in the header).
- ➖ NOT FIXING — the list is intentionally a plain table; the menu anchors inside the row. Cosmetic taste, not a defect. Files list on desktop is a narrow strip in a sea of empty space for small folders; the row menu opens off the right edge of the row. Cosmetic.
- ✅ RESOLVED — after a 502 the client leaves that machine alone for 5 s: 2 requests in 10 s at a 250 ms refresh (would have been ~80), badge and message intact. Offline machine keeps polling every view at the refresh rate, producing a 502 per request per page (and console errors). The UI copes — badge, message — but the hub should back off to the prober's cadence while a node is down.
- ✅ RESOLVED with 6. Services logs are noise. The default view of
hamlab.servicelogs is the sudo session spam from bug 6, three lines per poll.
Confirmed working under abuse (for the record)
Path traversal in files, services, cron, ZFS, procs and updates all rejected. /etc
and / protected from delete/rename as root. Unit, pool, dataset, snapshot and
package names validated. Token issue refused with a wrong password. Oversized prefs
rejected. Duplicate machine add refused. Unicode + spaces in file names round-trip.
Upload byte-exact. Terminal scrollback survives switching. Offline node: switcher and
header show it, pages show the reason. Remote terminals, logs, tasks and files through
the proxy all work on all three nodes.
Bug bash — 2026-09-19
Found by driving a scratch instance (same binary, its own state, real fleet behind it) in headless Chromium at 1440 px and iPhone size: a sweep of every page on every machine for page errors, failed requests, overflow and small tap targets; then scripted use of every feature — terminal tabs, Files in a throwaway folder, Processes (killing a process the test spawned), Monitors customisation and widgets, Services, Cron, Updates, Disks, Machines, Settings, embeds, machine switching, session loss — and a hostile pass over the API. Host-changing actions (service control, cron edits, upgrades, power) were opened but not executed. Every fix below was re-checked by the same script that found it.
Bugs — all ✅ fixed and verified
- ✅ One widget with a bad pattern silenced every custom widget. They share one stream;
fan.[made the whole query a 400, so all of them showed "…" forever with no reason given. The picker now asks the API before adding and shows its answer; a bad pattern already saved shows a notice instead of dots. - ✅ A saved layout of the wrong shape could not be loaded ("could not load layout",
and the page could no longer be customised).
normalize()now accepts anything. - ✅ A lost session left pages saying "unauthenticated" for up to 30 s. A 401 from the hub now returns to the login form at once, and logging in returns to the same page. (A 401 relayed from a managed machine is that machine rejecting the hub, and doesn't.)
- ✅ The machine-name rule in "Add a machine" was an invalid regular expression under the
browser's
vmode, so it validated nothing and logged a console error on every render. - ✅ Two terminals opened in the same minute got the same name ("shell 09:45" twice). Now "shell 09:45 (2)".
- ✅ Names had no length limit — a 328-character terminal tab, a 5000-character token
name. 64 characters, refused with a reason; token names also refuse
/ \ < > ". - ✅ "+ file", "+ folder" and Rename accepted
../xanda/b, quietly acting outside the folder on screen. A name is now one path segment; Move… is how things go elsewhere. - ✅ Unsaved file edits were thrown away without a word when you picked another page or machine, or closed the tab. All three now ask; Cancel keeps you where you were.
- ✅ A mistyped path in Files was remembered, so a reload landed on the error. Only a folder that opened is remembered.
- ✅ Escape closed nothing: the machine menu, the file menu, the Power dialog, the widget picker. It closes all four.
- ✅ Reset on Monitors removed every custom widget with no confirmation. It asks, and says how many.
- ✅ A few bytes of query bought 27 MB of answer (
{"k0":"*",…×400}withfull=1) — and a read-only token is public on the demo page. A query may return 10 000 values (thirty whole machines); past that it is a 400 that says so. - ✅ No ceiling on live streams: 300 from one token were accepted. 32 per client address, 512 in all, then a 429 that says so; closed streams free their places.
- ✅ Anything under
/embed/lost the anti-framing headers, not just the widget page. Only/embed/widgetis frameable. - ✅ The new ZFS sensors ran three
sudocommands a minute on every machine — includingcore, which has the tools and no pool — and each wrote three lines to the journal: most ofhamlab.service's log was that. Pools are now read with one unprivilegedzpool list; a source that finds nothing is asked again ten times less often; and the installer's quiet list coverszpool|zfs list|status. - ✅ Phone: the filter boxes on Processes and Files, the path box and the "/" crumb were under 30 px. 36 px like every other control.
- ✅ Disks → datasets on a phone: rows ended 50 px before their own cells when scrolled
sideways (
min-widthwas less than the columns add up to).
Also: tab titles now say where you are ("Services · grid — Ham Lab"), so history and open tabs can be told apart.
Checked and fine
Terminal focus after every switch/close/return; scrollback after reload; exited shells;
30 tabs with the + still reachable; re-fit on resize; Back/Forward; unknown machine or page
in the URL; wrong password; XSS through terminal, token and file names (all rendered as
text); hidden files, filter, symlinks (broken ones marked), binary files, upload and
replace, folder delete needing the typed name, root toggle; process sort, filter and
Terminate (asks first); themes; password-change validation; duplicate or unreachable
machine; hub terminals not leaking into another machine's page; CORS only on sensor paths;
token list never exposing hashes; cookie flags; deep nesting, empty bodies, odd methods
and every= values on the sensor API; phone drawer, key bar and picker.
Quality of life — all ✅ done 2026-09-19, each verified in the browser
- ✅ Selections are in the URL.
#/grid/services/ssh.service,#/hub/cron/job:…,#/hub/files/%2Fetc: reload keeps them, links carry them, leaving a page drops them. - ✅ Browser Back with unsaved file edits asks, like page and machine changes do.
- ✅ No more browser
prompt()/confirm()boxes — all 30 are in-app dialogs (askYes,askTextinui.tsx): Enter submits, Escape or a tap outside cancels, destructive ones are red and say what they do ("Close", "Delete"), and they fit a phone. - ✅ Token creation is one dialog: name, patterns, and the machine's own sensor families as chips to click.
- ✅ Widgets can be edited — sensor, type and title — in place, from Customize.
- ✅ The Widgets gallery has a search over IDs, names and labels (so "gitea" finds its container's sensors).
- ✅ Status sensors keep a history (300 readings each: an hour of container memory, two days of drive wear), so their sparklines have a past.
- ✅ A stream that fails says why: "the token was rejected", "too many live streams are open", "that machine is unreachable", "can't reach Ham Lab" — in embeds and on Monitors.
- ✅ Pattern widgets get readable default titles:
cpu.core.*.usage→ "CPU core usage". - ✅ Terminal tabs reorder — drag on a desktop, ‹ › on a touch screen, remembered per machine in the browser — and Alt+Shift+←/→ switches tabs, Alt+Shift+T opens one, with the keyboard left in the terminal each time.
- ➖ "Log out everywhere" — built, then removed the same day at Mitchell's word: no need for it on a single-user console.
- ✅ Real clipboard shortcuts in the terminal, from a physical keyboard: Ctrl+C copies
when text is selected (and is still ^C when nothing is — the selection clears after a
copy, so a second Ctrl+C interrupts), Ctrl+V pastes, Ctrl+Shift+C / Ctrl+Shift+V do
the same unconditionally, Ctrl+Insert / Shift+Insert too. It rides the browser's own
copy and paste events, which xterm already answers, so it works over plain http on the
LAN where
navigator.clipboarddoes not exist; bracketed paste is honoured.