fix: bug scan — API hardening, safer disk picker, updater rollback, friendlier installer #19

Merged
daniel merged 7 commits from fix/bug-scan-hw-compat into main 2026-09-28 14:06:09 +02:00
Owner

Bug scan + "installable on more machines" pass. Seven commits, each self-contained. 410 tests, ruff check and ruff format --check green locally. Not yet validated on VM or hardware. Please run the smoke VM (UEFI + BIOS) before a release.

Security

  • App names are validated everywhere. POST /api/apps/remove with {"name": "/"} or ".." sent shutil.rmtree at the root fs or /var/lib/furtka. The ".." / "../catalog" cases were reproduced in a sandbox. Wiping users.json also reopened admin setup to the LAN.
  • .env writes: line breaks are refused, and $ values are single-quoted (compose turned pa$word into pa). Validation now runs before the write (a rejected /etc path used to be saved anyway).
  • Damaged users.json fails closed: /login answers 503 with recovery steps instead of an open create-admin form. save_users fsyncs.
  • API server:
    • ThreadingHTTPServer; state-changing POSTs are still serialised.
    • 1 MiB body cap and 30 s socket timeout.
    • POSTs are refused when Sec-Fetch-Site is anything other than same-origin.
    • Unexpected exceptions return a JSON 500.

Installer / hardware

  • Disk picker is built on lsblk -J with TRAN/ROTA:
    • SATA SSDs were labelled HDD and lost the recommendation.
    • zram, eMMC boot/RPMB areas, read-only and 0-byte disks are hidden.
    • Disks under 16 GB are greyed out, and USB disks rank last.
    • Disks over 2 TB on BIOS machines get a warning.
    • The boot stick is also detected via the archiso kernel args.
    • Step 2 only accepts a disk that was listed.
  • One install at a time. archinstall's exit code is tracked (OOM kill = error, not stuck forever), and disk-probe exceptions go to the log instead of a bare 500.
  • Retry page after a failed install; settings are kept.
  • systemd-networkd-wait-online --any on the target: no 2-minute boot stall with a second, unplugged NIC.
  • Wizard wording:
    • "Server name" with a .local preview, and "Pick a disk" with an erase warning.
    • Readable confirm table.
    • Names are lowercased instead of rejected; system account names are refused.
    • The console banner says a network cable with internet is required.

Runtime

  • Updater:
    • The symlink flip is atomic (rename).
    • Any error after the flip rolls back and restores the Caddyfile.
    • The health check survives read timeouts.
    • Every failure ends in an error stage that the settings page shows.
  • Stale install state (runner gone, lock free, >60 s) is cleared instead of blocking all later installs.
  • Catalog sync / Forgejo calls wrap socket and disk errors.
  • UI: a 401 redirects to /login instead of showing an empty app list.

Also

  • website/tools/logo-assets.py made ruff check . fail on main; that is fixed here.

Known follow-ups (not in this PR)

  • 32-bit UEFI (Bay Trail) needs GRUB i386-efi.
  • MBR on disks over 2 TB is only warned about.
  • archinstall is not pinned.
  • "Pull the stick, then reboot" vs. power off on low-RAM boxes.
  • HTTPS 301 redirect can lock out clients that use the IP.
  • Offline app reinstall fails.
  • Public /status.json.
  • furtka-api has Requires=docker.
  • Old version slots and tarballs are never cleaned up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73

Bug scan + "installable on more machines" pass. Seven commits, each self-contained. 410 tests, `ruff check` and `ruff format --check` green locally. **Not yet validated on VM or hardware.** Please run the smoke VM (UEFI + BIOS) before a release. ## Security - **App names are validated everywhere.** `POST /api/apps/remove` with `{"name": "/"}` or `".."` sent `shutil.rmtree` at the root fs or `/var/lib/furtka`. The `".."` / `"../catalog"` cases were reproduced in a sandbox. Wiping `users.json` also reopened admin setup to the LAN. - **`.env` writes:** line breaks are refused, and `$` values are single-quoted (compose turned `pa$word` into `pa`). Validation now runs before the write (a rejected `/etc` path used to be saved anyway). - **Damaged `users.json` fails closed:** `/login` answers 503 with recovery steps instead of an open create-admin form. `save_users` fsyncs. - **API server:** - `ThreadingHTTPServer`; state-changing POSTs are still serialised. - 1 MiB body cap and 30 s socket timeout. - POSTs are refused when `Sec-Fetch-Site` is anything other than `same-origin`. - Unexpected exceptions return a JSON 500. ## Installer / hardware - **Disk picker** is built on `lsblk -J` with `TRAN`/`ROTA`: - SATA SSDs were labelled HDD and lost the recommendation. - zram, eMMC boot/RPMB areas, read-only and 0-byte disks are hidden. - Disks under 16 GB are greyed out, and USB disks rank last. - Disks over 2 TB on BIOS machines get a warning. - The boot stick is also detected via the archiso kernel args. - Step 2 only accepts a disk that was listed. - **One install at a time.** archinstall's exit code is tracked (OOM kill = error, not stuck forever), and disk-probe exceptions go to the log instead of a bare 500. - **Retry page after a failed install;** settings are kept. - **systemd-networkd-wait-online `--any`** on the target: no 2-minute boot stall with a second, unplugged NIC. - **Wizard wording:** - "Server name" with a `.local` preview, and "Pick a disk" with an erase warning. - Readable confirm table. - Names are lowercased instead of rejected; system account names are refused. - The console banner says a network cable with internet is required. ## Runtime - **Updater:** - The symlink flip is atomic (rename). - Any error after the flip rolls back and restores the Caddyfile. - The health check survives read timeouts. - Every failure ends in an `error` stage that the settings page shows. - **Stale install state** (runner gone, lock free, >60 s) is cleared instead of blocking all later installs. - **Catalog sync / Forgejo calls** wrap socket and disk errors. - **UI:** a 401 redirects to `/login` instead of showing an empty app list. ## Also - `website/tools/logo-assets.py` made `ruff check .` fail on main; that is fixed here. ## Known follow-ups (not in this PR) - 32-bit UEFI (Bay Trail) needs GRUB i386-efi. - MBR on disks over 2 TB is only warned about. - archinstall is not pinned. - "Pull the stick, then reboot" vs. power off on low-RAM boxes. - HTTPS 301 redirect can lock out clients that use the IP. - Offline app reinstall fails. - Public `/status.json`. - `furtka-api` has `Requires=docker`. - Old version slots and tarballs are never cleaned up. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
daniel added 7 commits 2026-09-28 13:31:11 +02:00
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- drives: classify by lsblk TRAN/ROTA (SATA SSDs were labelled HDD and lost
  to bigger spinning disks); drop zram, eMMC boot/rpmb areas, read-only and
  0-byte disks; grey out disks under 16 GB; rank USB disks last; warn about
  >2 TB disks on BIOS (MBR); boot-stick fallback via archiso kernel args
  when bootmnt is gone; timeouts on lsblk/smartctl/blkid
- step 2 only accepts a disk that was actually offered
- /install/run refuses a second concurrent install and disables the button
- archinstall's exit code is tracked: OOM kills / signals now show as errors
  instead of a progress bar stuck forever
- disk-probe exceptions land in the install log instead of a bare 500

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- app names from URL paths and JSON bodies must match APP_NAME_RE; installer
  remove()/update_env() check it too. Before, {"name": ".."} or "/" sent
  remove() into shutil.rmtree on /var/lib/furtka or the root filesystem
- manifests must carry a valid app name as well
- settings are validated in memory (placeholders, system paths, line breaks)
  before .env is written — a rejected /etc used to be saved anyway
- values containing $ are single-quoted so compose doesn't expand them
  ("pa$word" became "pa"); line breaks are refused (env injection)
- ThreadingHTTPServer so status polls keep answering during slow requests;
  state-changing POSTs still run one at a time
- 1 MiB body cap, 30 s socket timeout, bad Content-Length → 400
- POSTs with Sec-Fetch-Site other than same-origin are refused (apps on
  other ports of the same host count as same-site for SameSite=Strict)
- unexpected exceptions return a JSON 500 instead of a dropped connection
- UI pages send the browser to /login on a 401 instead of showing an empty
  app list after an API restart

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
updater:
- /opt/furtka/current is swapped via rename() instead of unlink+symlink,
  so a power cut can't leave the box without a current version
- OSError after the flip (disk full writing the Caddyfile) now rolls back
  too; rollback restores the previous Caddyfile and reloads caddy +
  daemon-reload
- the health check keeps polling on read timeouts / RemoteDisconnected
  instead of escaping and skipping the rollback
- run_update writes an 'error' stage for any failure (download, sha,
  disk full); the settings page shows it instead of spinning on
  'Downloading…'

auth:
- users.json that exists but is empty/corrupt/unreadable raises instead of
  reading as 'no admin yet' — before, a truncated file made /login an open
  create-admin form for the whole LAN. /login answers 503 with SSH recovery
  steps instead
- save_users fsyncs before the rename
- first-run admin creation is serialised (threaded server)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
- an unfinished install-state.json whose runner is gone (free lock, >60 s
  old — reboot, OOM kill, runner never started) is marked 'error' instead
  of blocking every later install with 409 until someone fixes it over SSH
- read_state in install_runner/updater tolerates unreadable and non-object
  files
- Forgejo API + release downloads wrap OSError and HTTPException (socket
  timeouts, resets, disk full) — the update/catalog check endpoints used to
  drop the connection on a flaky network
- catalog sync records any failure as the 'error' stage, not just
  CatalogError

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
- error state gets a 'Try again' button (settings are kept) plus links back
  to the disk / settings pages; the page reloads into it instead of only
  recolouring the bar
- names are lowercased instead of rejected; system account names (root,
  caddy, docker, …) are refused with a clear message
- plain wording: 'Server name' with a live http://<name>.local preview,
  'Pick a disk' with an explicit erase warning, a readable confirm table
  (language label, disk size/type) and a Back button
- the password hint says it is also the SSH / console password; the
  language hint says the wizard itself is English
- wizard pages redirect to the progress page while an install is running
- progress page: aria-live phase, aria-valuenow updates, 'lost connection'
  note, 'fine to close this page'
- .local fallback on the reboot page: router device list, IP may change
- live console banner says a network cable with internet is required
- target gets a systemd-networkd-wait-online --any drop-in: boards with a
  second unplugged NIC (or Ethernet + Wi-Fi) waited up to 2 min at boot

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
docs(changelog): bug-scan fixes under Unreleased
All checks were successful
CI / lint (pull_request) Successful in 29s
CI / validate-json (pull_request) Successful in 24s
CI / markdown-links (pull_request) Successful in 15s
CI / test (pull_request) Successful in 1m42s
b7b66fe136
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
daniel merged commit 4c5bfe3aef into main 2026-09-28 14:06:09 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: daniel/furtka#19
No description provided.