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
29 changed files with 1445 additions and 359 deletions

View file

@ -7,8 +7,50 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased] ## [Unreleased]
### Security
- **App names are validated everywhere.** `POST /api/apps/remove` with
`{"name": ".."}` or `"/"` sent `shutil.rmtree` at `/var/lib/furtka` or the
root filesystem (and wiping `users.json` reopened first-run admin setup to
the LAN). Names from URLs, JSON bodies and manifests must now match
`^[a-z][a-z0-9_-]*$`; `installer.remove`/`update_env` check it too.
- **Settings can't inject into `.env`.** Line breaks are refused; values
with `$` are single-quoted so compose doesn't expand them (`pa$word` used
to become `pa`). Validation runs before the write, so a rejected `/etc`
path is no longer saved anyway.
- **Damaged `users.json` fails closed.** An empty/corrupt file used to read
as "no admin yet" and turned `/login` into an open create-admin form.
`/login` now answers 503 with SSH recovery steps; `save_users` fsyncs.
- **API server hardening:** threaded (state changes still serialised), 1 MiB
body cap, 30 s socket timeout, cross-site POSTs refused via
`Sec-Fetch-Site`, JSON 500 instead of dropped connections.
### Fixed ### Fixed
- **Installer disk picker:** SATA SSDs were labelled "HDD" and lost the
recommendation to bigger spinning disks; type now comes from lsblk
`TRAN`/`ROTA`. zram, eMMC boot/RPMB areas, read-only and 0-byte disks are
hidden; disks under 16 GB are greyed out; USB disks rank last; >2 TB disks
on BIOS machines get an MBR warning; the boot stick is also found via the
archiso kernel args when `bootmnt` is gone. Step 2 only accepts a listed disk.
- **Installer runs one install at a time** (double-click / second browser
started a second archinstall on the same disk), and a crashed archinstall
(OOM kill, signal) now shows as an error instead of a progress bar stuck
forever. Disk-probe exceptions land in the log instead of a bare 500.
- **Failed installs can be retried** from the error page without re-entering
everything.
- **No 2-minute boot stall on boards with a second NIC:** the target gets a
`systemd-networkd-wait-online --any` drop-in.
- **Self-update:** symlink flip is atomic; any error after the flip rolls
back (and restores the previous Caddyfile); health check survives read
timeouts; every failure ends in an `error` stage the settings page shows.
- **Interrupted app installs no longer block all later installs** — a stale
state with no live runner is marked failed after 60 s.
- **Catalog sync / update checks** no longer strand on socket resets or disk
full.
- **Apps page after an API restart** redirects to `/login` instead of
showing an empty app list.
- **Self-update starts newly added timers immediately and links units - **Self-update starts newly added timers immediately and links units
through `current`.** `_link_new_units()` enabled a timer a release through `current`.** `_link_new_units()` enabled a timer a release
introduced but never started it, so after the 26.19 → 26.20 update the introduced but never started it, so after the 26.19 → 26.20 update the
@ -19,6 +61,14 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
(installer-created links use `current`). Now: link via `current`, enable, (installer-created links use `current`). Now: link via `current`, enable,
start. start.
### Changed
- Wizard wording: "Server name" with a live `http://<name>.local` preview,
"Pick a disk" with an explicit erase warning, readable confirm table,
password hint explains it's also the SSH/console password, names are
lowercased instead of rejected, system account names are refused.
- Live console banner says a network cable with internet is required.
## [26.20-alpha] - 2026-08-25 ## [26.20-alpha] - 2026-08-25
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot, First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,

View file

@ -68,6 +68,15 @@
</main> </main>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Revoke the cookie server-side and bounce to /login. Shared // Revoke the cookie server-side and bounce to /login. Shared
// shape with the _HTML in furtka/api.py so the two logout // shape with the _HTML in furtka/api.py so the two logout
// links behave identically. // links behave identically.

View file

@ -122,6 +122,15 @@
</main> </main>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
// Logout button in the nav — same shape as /apps and / pages. // Logout button in the nav — same shape as /apps and / pages.
async function doLogout(ev) { async function doLogout(ev) {
ev.preventDefault(); ev.preventDefault();
@ -162,6 +171,7 @@
done: 'Update complete — reloading…', done: 'Update complete — reloading…',
rolled_back: 'Update failed, rolled back to the previous version', rolled_back: 'Update failed, rolled back to the previous version',
rolled_back_manual: 'Rolled back manually', rolled_back_manual: 'Rolled back manually',
error: 'Update failed — nothing was changed',
}; };
let pollHandle = null; let pollHandle = null;
@ -345,12 +355,13 @@
if (!r.ok) return; if (!r.ok) return;
const s = await r.json(); const s = await r.json();
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`; const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
setStatus(label, s.stage === 'rolled_back'); const failed = s.stage === 'rolled_back' || s.stage === 'error';
setStatus(label, failed);
if (s.stage === 'done') { if (s.stage === 'done') {
clearInterval(pollHandle); clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle); clearTimeout(fallbackReloadHandle);
setTimeout(() => location.reload(), 5000); setTimeout(() => location.reload(), 5000);
} else if (s.stage === 'rolled_back') { } else if (failed) {
clearInterval(pollHandle); clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle); clearTimeout(fallbackReloadHandle);
if (s.reason) { if (s.reason) {

View file

@ -15,6 +15,7 @@ neutral ``ReleaseError`` for use in tests or standalone scripts.
from __future__ import annotations from __future__ import annotations
import hashlib import hashlib
import http.client
import json import json
import shutil import shutil
import tarfile import tarfile
@ -33,7 +34,9 @@ def forgejo_api(host: str, repo: str, path: str, *, error_cls: type = ReleaseErr
try: try:
with urllib.request.urlopen(req, timeout=15) as resp: with urllib.request.urlopen(req, timeout=15) as resp:
return json.loads(resp.read()) return json.loads(resp.read())
except (urllib.error.URLError, json.JSONDecodeError) as e: except (OSError, http.client.HTTPException, json.JSONDecodeError) as e:
# OSError covers URLError plus socket timeouts and resets while
# reading the body, which urllib doesn't wrap.
raise error_cls(f"forgejo api {url}: {e}") from e raise error_cls(f"forgejo api {url}: {e}") from e
@ -43,7 +46,8 @@ def download(url: str, dest: Path, *, error_cls: type = ReleaseError) -> None:
try: try:
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f: with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
shutil.copyfileobj(resp, f) shutil.copyfileobj(resp, f)
except urllib.error.URLError as e: except (OSError, http.client.HTTPException) as e:
# Includes disk full while writing `dest` and mid-body disconnects.
raise error_cls(f"download {url}: {e}") from e raise error_cls(f"download {url}: {e}") from e

View file

@ -17,12 +17,14 @@ stopgap.
import json import json
import re import re
import threading
import time import time
import traceback
from http.cookies import SimpleCookie from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources
from furtka.manifest import ManifestError, load_manifest from furtka.manifest import APP_NAME_RE, ManifestError, load_manifest
from furtka.paths import apps_dir, static_www_dir from furtka.paths import apps_dir, static_www_dir
from furtka.scanner import scan from furtka.scanner import scan
@ -120,6 +122,15 @@ _HTML = """<!DOCTYPE html>
</div> </div>
<script> <script>
// Sessions live in API memory, so every restart or self-update logs
// everyone out. Send the browser to the login page instead of rendering
// the 401 body as if the box had no apps.
const _fetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const r = await _fetch(...args);
if (r.status === 401) { location.href = '/login'; }
return r;
};
function esc(s) { function esc(s) {
const d = document.createElement('div'); const d = document.createElement('div');
d.textContent = s == null ? '' : String(s); d.textContent = s == null ? '' : String(s);
@ -765,7 +776,7 @@ def _do_get_settings(name):
} }
_INSTALL_TERMINAL_STAGES = frozenset({"done", "error"}) _INSTALL_TERMINAL_STAGES = install_runner.TERMINAL_STAGES
def _do_install_plan(name): def _do_install_plan(name):
@ -819,8 +830,9 @@ def _do_install(name, settings=None, confirm_dependencies=False):
# POST can sneak in during that tiny window. Reading the state # POST can sneak in during that tiny window. Reading the state
# first closes that gap: as long as a previous install hasn't # first closes that gap: as long as a previous install hasn't
# written "done" or "error", we refuse. # written "done" or "error", we refuse.
install_runner.clear_stale_state()
current_state = install_runner.read_state() current_state = install_runner.read_state()
current_stage = current_state.get("stage", "") if isinstance(current_state, dict) else "" current_stage = current_state.get("stage", "")
if current_stage and current_stage not in _INSTALL_TERMINAL_STAGES: if current_stage and current_stage not in _INSTALL_TERMINAL_STAGES:
return 409, { return 409, {
"error": ( "error": (
@ -916,6 +928,7 @@ def _do_install(name, settings=None, confirm_dependencies=False):
def _do_install_status(): def _do_install_status():
"""Return the current install-state.json contents (or {}).""" """Return the current install-state.json contents (or {})."""
install_runner.clear_stale_state()
return 200, install_runner.read_state() return 200, install_runner.read_state()
@ -1238,7 +1251,33 @@ def _parse_settings_body(payload):
return out return out
# Largest request body we read. Every legitimate body is a small JSON
# object; without a cap a LAN client could make us buffer gigabytes.
MAX_BODY_BYTES = 1024 * 1024
# The server is threaded so status polls and page loads keep answering while
# a slow request (image pull, reconcile) runs. Requests that change state
# still run one at a time, as they did on the single-threaded server.
_MUTATION_LOCK = threading.Lock()
# First-run admin creation is check-then-write; two setup POSTs racing on
# the threaded server must not both create an admin.
_SETUP_LOCK = threading.Lock()
_USERS_FILE_BROKEN = (
"The login file on this box (/var/lib/furtka/users.json) is damaged. "
"Log in over SSH, delete that file, then reload this page to set a new admin password."
)
def _valid_app_name(name) -> bool:
return isinstance(name, str) and APP_NAME_RE.fullmatch(name) is not None
class _Handler(BaseHTTPRequestHandler): class _Handler(BaseHTTPRequestHandler):
# Socket read timeout: a client that stops sending mid-request gets
# dropped instead of holding a worker forever.
timeout = 30
def _json(self, status, payload, extra_headers=None): def _json(self, status, payload, extra_headers=None):
body = json.dumps(payload).encode() body = json.dumps(payload).encode()
self.send_response(status) self.send_response(status)
@ -1347,6 +1386,14 @@ class _Handler(BaseHTTPRequestHandler):
return self._json(400, {"error": "password is required"}) return self._json(400, {"error": "password is required"})
username = username.strip() username = username.strip()
try:
with _SETUP_LOCK:
return self._login_or_setup(payload, username, password)
except auth.UsersFileError:
traceback.print_exc()
return self._json(503, {"error": _USERS_FILE_BROKEN})
def _login_or_setup(self, payload, username, password):
if auth.setup_needed(): if auth.setup_needed():
# First-run setup path — create the admin account, then log # First-run setup path — create the admin account, then log
# in. Require password2 so a typo doesn't lock the user out # in. Require password2 so a typo doesn't lock the user out
@ -1393,13 +1440,39 @@ class _Handler(BaseHTTPRequestHandler):
auth.SESSIONS.revoke(morsel.value) auth.SESSIONS.revoke(morsel.value)
return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()]) return self._json(200, {"ok": True}, extra_headers=[self._clear_cookie_header()])
def _internal_error(self):
# Without this, an unexpected exception makes socketserver drop the
# connection and the UI shows "Failed to fetch" with nothing to go on.
traceback.print_exc()
try:
self._json(500, {"error": "internal error — see `journalctl -u furtka-api`"})
except OSError:
pass
def do_GET(self): # noqa: N802 — http.server convention def do_GET(self): # noqa: N802 — http.server convention
try:
self._route_get()
except Exception:
self._internal_error()
def do_POST(self): # noqa: N802
try:
self._route_post()
except Exception:
self._internal_error()
def _route_get(self):
# --- Public routes: login page + its assets ------------------ # --- Public routes: login page + its assets ------------------
if self.path in ("/login", "/login/"): if self.path in ("/login", "/login/"):
# Already authed? Skip straight to the app list. # Already authed? Skip straight to the app list.
if self._current_session() is not None: if self._current_session() is not None:
return self._redirect("/apps") return self._redirect("/apps")
return self._html(200, _render_login_html(auth.setup_needed())) try:
setup = auth.setup_needed()
except auth.UsersFileError:
traceback.print_exc()
return self._html(503, f"<h1>Login unavailable</h1><p>{_USERS_FILE_BROKEN}</p>")
return self._html(200, _render_login_html(setup))
# --- Auth guard for everything below ------------------------- # --- Auth guard for everything below -------------------------
session = self._current_session() session = self._current_session()
@ -1447,14 +1520,26 @@ class _Handler(BaseHTTPRequestHandler):
# /api/apps/<name>/settings # /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_get_settings(name) status, body = _do_get_settings(name)
return self._json(status, body) return self._json(status, body)
self._json(404, {"error": "not found"}) self._json(404, {"error": "not found"})
def do_POST(self): # noqa: N802 def _route_post(self):
length = int(self.headers.get("Content-Length", "0")) # Browsers tag every request with where it came from. Only accept
# state changes from our own pages — an app on another port of the
# same host counts as "same-site" and would pass SameSite=Strict.
# Old browsers that don't send the header fall back to the cookie.
fetch_site = self.headers.get("Sec-Fetch-Site")
if fetch_site is not None and fetch_site not in ("same-origin", "none"):
return self._json(403, {"error": "cross-site request refused"})
try:
length = int(self.headers.get("Content-Length", "0"))
except ValueError:
return self._json(400, {"error": "invalid Content-Length"})
if length < 0 or length > MAX_BODY_BYTES:
return self._json(413, {"error": "request body too large"})
raw = self.rfile.read(length) if length else b"" raw = self.rfile.read(length) if length else b""
try: try:
payload = json.loads(raw.decode()) if raw else {} payload = json.loads(raw.decode()) if raw else {}
@ -1473,10 +1558,15 @@ class _Handler(BaseHTTPRequestHandler):
if self._current_session() is None: if self._current_session() is None:
return self._json(401, {"error": "not authenticated"}) return self._json(401, {"error": "not authenticated"})
with _MUTATION_LOCK:
return self._route_authed_post(payload)
def _route_authed_post(self, payload):
# Per-app settings update: /api/apps/<name>/settings # Per-app settings update: /api/apps/<name>/settings
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"): if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
name = self.path[len("/api/apps/") : -len("/settings")] name = self.path[len("/api/apps/") : -len("/settings")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
settings = _parse_settings_body(payload) settings = _parse_settings_body(payload)
if settings is False or settings is None: if settings is False or settings is None:
@ -1487,7 +1577,7 @@ class _Handler(BaseHTTPRequestHandler):
# Per-app image update: /api/apps/<name>/update # Per-app image update: /api/apps/<name>/update
if self.path.startswith("/api/apps/") and self.path.endswith("/update"): if self.path.startswith("/api/apps/") and self.path.endswith("/update"):
name = self.path[len("/api/apps/") : -len("/update")] name = self.path[len("/api/apps/") : -len("/update")]
if "/" in name or not name: if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"}) return self._json(400, {"error": "invalid app name"})
status, body = _do_update(name) status, body = _do_update(name)
return self._json(status, body) return self._json(status, body)
@ -1519,6 +1609,8 @@ class _Handler(BaseHTTPRequestHandler):
name = payload.get("name") name = payload.get("name")
if not isinstance(name, str) or not name: if not isinstance(name, str) or not name:
return self._json(400, {"error": "missing or empty 'name' field"}) return self._json(400, {"error": "missing or empty 'name' field"})
if not _valid_app_name(name):
return self._json(400, {"error": "invalid app name"})
if self.path == "/api/apps/install/plan": if self.path == "/api/apps/install/plan":
status, body = _do_install_plan(name) status, body = _do_install_plan(name)
@ -1541,7 +1633,8 @@ class _Handler(BaseHTTPRequestHandler):
def serve(host: str = "127.0.0.1", port: int = 7000) -> None: def serve(host: str = "127.0.0.1", port: int = 7000) -> None:
"""Run the API server. Blocks forever; exits on SIGINT/SIGTERM.""" """Run the API server. Blocks forever; exits on SIGINT/SIGTERM."""
server = HTTPServer((host, port), _Handler) server = ThreadingHTTPServer((host, port), _Handler)
server.daemon_threads = True
print(f"Furtka API listening on http://{host}:{port}/") print(f"Furtka API listening on http://{host}:{port}/")
try: try:
server.serve_forever() server.serve_forever()

View file

@ -23,6 +23,7 @@ from __future__ import annotations
import json import json
import math import math
import os
import secrets import secrets
import threading import threading
from dataclasses import dataclass from dataclasses import dataclass
@ -46,27 +47,33 @@ def verify_password(plain: str, hashed: str) -> bool:
return _verify_password(plain, hashed) return _verify_password(plain, hashed)
class UsersFileError(RuntimeError):
"""users.json exists but can't be read or parsed."""
def load_users() -> dict: def load_users() -> dict:
"""Return the users dict, or {} if the file is missing or empty. """Return the users dict, or {} if the file is missing.
Missing-file is the expected state on first boot and on upgrades from Missing-file is the expected state on first boot and on upgrades from
pre-auth versions — callers treat empty-dict as "setup required". pre-auth versions — callers treat empty-dict as "setup required".
A file that exists but is unreadable, empty or corrupt raises
UsersFileError instead: treating it as "no admin yet" would turn /login
into an open "create admin" form for anyone on the network.
""" """
path = users_file() path = users_file()
if not path.exists():
return {}
try: try:
raw = path.read_text() raw = path.read_text()
except OSError: except FileNotFoundError:
return {}
if not raw.strip():
return {} return {}
except (OSError, UnicodeDecodeError) as e:
raise UsersFileError(f"{path} is unreadable: {e}") from e
try: try:
data = json.loads(raw) data = json.loads(raw)
except json.JSONDecodeError: except json.JSONDecodeError as e:
return {} raise UsersFileError(f"{path} is corrupt: {e}") from e
if not isinstance(data, dict): if not isinstance(data, dict):
return {} raise UsersFileError(f"{path} is corrupt: not a JSON object")
return data return data
@ -80,7 +87,12 @@ def save_users(users: dict) -> None:
path = users_file() path = users_file()
path.parent.mkdir(parents=True, exist_ok=True) path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp") tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(users, indent=2) + "\n") with open(tmp, "w") as fh:
fh.write(json.dumps(users, indent=2) + "\n")
# A power cut right after rename() can otherwise leave a zero-length
# users.json, which load_users() refuses — locking the owner out.
fh.flush()
os.fsync(fh.fileno())
tmp.chmod(0o600) tmp.chmod(0o600)
tmp.replace(path) tmp.replace(path)

View file

@ -216,6 +216,9 @@ def sync_catalog() -> CatalogCheck:
except CatalogError as e: except CatalogError as e:
write_state("error", error=str(e)) write_state("error", error=str(e))
raise raise
except Exception as e:
write_state("error", error=str(e) or type(e).__name__)
raise CatalogError(str(e) or type(e).__name__) from e
def _sync_locked() -> CatalogCheck: def _sync_locked() -> CatalogCheck:

View file

@ -91,9 +91,53 @@ def write_state(stage: str, **extra) -> None:
def read_state() -> dict: def read_state() -> dict:
try: try:
return json.loads(state_path().read_text()) data = json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError): except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {} return {}
return data if isinstance(data, dict) else {}
TERMINAL_STAGES = frozenset({"done", "error"})
# How old an unfinished state must be before we believe the job is gone.
# Covers the gap between the API writing the first stage and the detached
# runner taking the lock.
_STALE_AFTER_SECONDS = 60
def clear_stale_state(now: float | None = None) -> bool:
"""Mark an unfinished install as failed if no runner is alive any more.
install-state.json lives in /var/lib and survives reboots, OOM kills and
a runner that never started — each of which used to block every later
install with "another install is in progress" until someone fixed it
over SSH. The runner holds the flock for its whole run, so a free lock
plus an old timestamp means nobody is coming back to finish. Returns
True when it reset the state.
"""
state = read_state()
stage = state.get("stage", "")
if not stage or stage in TERMINAL_STAGES:
return False
try:
updated = time.mktime(time.strptime(state.get("updated_at", ""), "%Y-%m-%dT%H:%M:%S%z"))
except (TypeError, ValueError):
updated = 0.0
if (now if now is not None else time.time()) - updated < _STALE_AFTER_SECONDS:
return False
try:
fh = acquire_lock()
except (InstallRunnerError, OSError):
return False # a runner is still working
try:
write_state(
"error",
app=state.get("app", ""),
error=f"the install was interrupted at '{stage}' (restart or crash) — try again",
)
finally:
fh.close()
return True
def _read_plan(target: str) -> dict: def _read_plan(target: str) -> dict:

View file

@ -2,7 +2,7 @@ import shutil
from pathlib import Path from pathlib import Path
from furtka import sources from furtka import sources
from furtka.manifest import Manifest, ManifestError, load_manifest from furtka.manifest import APP_NAME_RE, Manifest, ManifestError, load_manifest
from furtka.paths import apps_dir from furtka.paths import apps_dir
# Values that an app's .env.example may use as obvious "fill me in" markers. # Values that an app's .env.example may use as obvious "fill me in" markers.
@ -35,19 +35,26 @@ class InstallError(RuntimeError):
pass pass
def app_dir(name: str) -> Path:
"""/var/lib/furtka/apps/<name>, refusing anything that isn't a plain app name.
Names reach us from API bodies and URL paths; without this check "..",
"." or "/" would point remove()'s rmtree at /var/lib/furtka, every app,
or the root filesystem.
"""
if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise InstallError(f"{name!r} is not a valid app name")
return apps_dir() / name
def _placeholder_values(values: dict[str, str]) -> list[str]:
return [k for k, v in values.items() if v.strip() in PLACEHOLDER_SECRETS]
def _placeholder_keys(env_path: Path) -> list[str]: def _placeholder_keys(env_path: Path) -> list[str]:
if not env_path.exists(): if not env_path.exists():
return [] return []
bad: list[str] = [] return _placeholder_values(_read_env(env_path))
for raw in env_path.read_text().splitlines():
line = raw.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
value = value.strip().strip('"').strip("'")
if value in PLACEHOLDER_SECRETS:
bad.append(key.strip())
return bad
def _is_denied_system_path(resolved: str) -> bool: def _is_denied_system_path(resolved: str) -> bool:
@ -59,16 +66,13 @@ def _is_denied_system_path(resolved: str) -> bool:
return False return False
def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]: def _path_setting_errors(m: Manifest, values: dict[str, str]) -> list[str]:
"""Validate the filesystem paths named by `path`-type settings. """Validate the filesystem paths named by `path`-type settings.
Returns one human-readable message per offending setting. Empty values Returns one human-readable message per offending setting. Empty values
on non-required settings are allowed — the required-field check in the on non-required settings are allowed — the required-field check in the
caller already refuses blanks on required fields before write. caller already refuses blanks on required fields before write.
""" """
if not env_path.exists():
return []
values = _read_env(env_path)
errors: list[str] = [] errors: list[str] = []
for s in m.settings: for s in m.settings:
if s.type != "path": if s.type != "path":
@ -97,10 +101,35 @@ def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]:
return errors return errors
def _validate_settings(m: Manifest, values: dict[str, str]) -> None:
"""Refuse a merged settings view before it is written to .env.
Runs in memory so a rejected value (placeholder, system path, line
break) never reaches the live .env where the next reconcile would use it.
"""
for k, v in values.items():
if any(c in v for c in "\r\n\0"):
raise InstallError(f"{m.name}: setting {k!r} must not contain line breaks")
bad = _placeholder_values({s.name: values.get(s.name, "") for s in m.settings})
if bad:
raise InstallError(
f"{m.name}: please replace the example value for {', '.join(bad)} with your own"
)
path_errors = _path_setting_errors(m, values)
if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
def _format_env_value(v: str) -> str: def _format_env_value(v: str) -> str:
# Quote values that contain whitespace, quotes, or shell metacharacters so # Quote values that contain whitespace, quotes, or shell metacharacters so
# docker-compose's env substitution reads them back intact. Simple values # docker-compose's env substitution reads them back intact. Simple values
# stay unquoted to keep the file readable when a user SSHes in. # stay unquoted to keep the file readable when a user SSHes in.
if any(c in v for c in "\r\n\0"):
raise InstallError("setting values must not contain line breaks")
if "$" in v and "'" not in v:
# Compose expands ${VAR} inside double quotes, so "pa$word" would
# silently turn into "pa". Single-quoted values are taken literally.
return f"'{v}'"
if v == "" or any(c in v for c in " \t\"'$`\\#"): if v == "" or any(c in v for c in " \t\"'$`\\#"):
escaped = v.replace("\\", "\\\\").replace('"', '\\"') escaped = v.replace("\\", "\\\\").replace('"', '\\"')
return f'"{escaped}"' return f'"{escaped}"'
@ -167,7 +196,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
if key not in declared: if key not in declared:
raise InstallError(f"{m.name}: unknown setting {key!r}") raise InstallError(f"{m.name}: unknown setting {key!r}")
target = apps_dir() / m.name target = app_dir(m.name)
target.mkdir(parents=True, exist_ok=True) target.mkdir(parents=True, exist_ok=True)
for item in src.iterdir(): for item in src.iterdir():
@ -216,6 +245,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
for s in m.settings: for s in m.settings:
if s.required and not merged.get(s.name): if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required") raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged) write_env(env, merged)
elif not env.exists() and env_example.exists(): elif not env.exists() and env_example.exists():
# First install with no settings and no .env shipped: bootstrap from # First install with no settings and no .env shipped: bootstrap from
@ -235,7 +265,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
f"file and re-run `furtka app install {m.name}`." f"file and re-run `furtka app install {m.name}`."
) )
path_errors = _path_setting_errors(m, env) path_errors = _path_setting_errors(m, read_env_values(env))
if path_errors: if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}") raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
@ -256,10 +286,11 @@ def parse_env_text(text: str) -> dict[str, str]:
continue continue
key, _, value = line.partition("=") key, _, value = line.partition("=")
value = value.strip() value = value.strip()
if (value.startswith('"') and value.endswith('"')) or ( if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
value.startswith("'") and value.endswith("'")
):
value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\") value = value[1:-1].replace('\\"', '"').replace("\\\\", "\\")
elif len(value) >= 2 and value.startswith("'") and value.endswith("'"):
# Single quotes are literal in compose's dotenv — no escapes.
value = value[1:-1]
out[key.strip()] = value out[key.strip()] = value
return out return out
@ -284,7 +315,7 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
apps only. Returns the target folder; caller is expected to run apps only. Returns the target folder; caller is expected to run
reconcile to restart the containers. reconcile to restart the containers.
""" """
target = apps_dir() / name target = app_dir(name)
manifest_path = target / "manifest.json" manifest_path = target / "manifest.json"
if not manifest_path.exists(): if not manifest_path.exists():
raise InstallError(f"{name!r} is not installed") raise InstallError(f"{name!r} is not installed")
@ -316,13 +347,8 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
for s in m.settings: for s in m.settings:
if s.required and not merged.get(s.name): if s.required and not merged.get(s.name):
raise InstallError(f"{m.name}: setting {s.name!r} is required") raise InstallError(f"{m.name}: setting {s.name!r} is required")
_validate_settings(m, merged)
write_env(env, merged) write_env(env, merged)
bad = _placeholder_keys(env)
if bad:
raise InstallError(f"{m.name}: {env} still has placeholder values for {', '.join(bad)}.")
path_errors = _path_setting_errors(m, env)
if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
return target return target
@ -353,7 +379,7 @@ def remove(name: str) -> Path:
Caller is responsible for stopping the compose project first. Caller is responsible for stopping the compose project first.
""" """
target = apps_dir() / name target = app_dir(name)
if not target.exists(): if not target.exists():
raise InstallError(f"{name!r} is not installed") raise InstallError(f"{name!r} is not installed")
shutil.rmtree(target) shutil.rmtree(target)

View file

@ -175,8 +175,10 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}") raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
name = raw["name"] name = raw["name"]
if not isinstance(name, str) or not name: if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
raise ManifestError(f"{path}: name must be a non-empty string") raise ManifestError(
f"{path}: name must be lowercase letters, digits, '-' or '_' (got {name!r})"
)
if expected_name is not None and name != expected_name: if expected_name is not None and name != expected_name:
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}") raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")

View file

@ -29,6 +29,7 @@ the updater at a tmpdir.
from __future__ import annotations from __future__ import annotations
import fcntl import fcntl
import http.client
import json import json
import os import os
import shutil import shutil
@ -265,9 +266,10 @@ def write_state(stage: str, **extra) -> None:
def read_state() -> dict: def read_state() -> dict:
try: try:
return json.loads(state_path().read_text()) data = json.loads(state_path().read_text())
except (FileNotFoundError, json.JSONDecodeError): except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {} return {}
return data if isinstance(data, dict) else {}
def acquire_lock(): def acquire_lock():
@ -318,13 +320,29 @@ def _health_check(url: str, deadline_s: float = 30.0) -> bool:
# check. 5xx → genuinely broken, don't accept. # check. 5xx → genuinely broken, don't accept.
if 400 <= e.code < 500: if 400 <= e.code < 500:
return True return True
except urllib.error.URLError: except (OSError, http.client.HTTPException):
# Connection refused / DNS / timeout → not up yet, retry. # Connection refused / reset / timeout (URLError is an OSError;
# a read timeout or RemoteDisconnected is not wrapped by urllib)
# → not up yet, retry.
pass pass
time.sleep(1) time.sleep(1)
return False return False
def _swap_symlink(link: Path, target: str | Path) -> None:
"""Point `link` at `target` atomically.
unlink() + symlink_to() leaves a window with no /opt/furtka/current at
all — a power cut there boots a box with no services. rename() over the
old link is atomic.
"""
tmp = link.with_name(link.name + ".new")
if tmp.is_symlink() or tmp.exists():
tmp.unlink()
tmp.symlink_to(target)
os.replace(tmp, link)
def prepare_update(check: UpdateCheck, download_dir: Path | None = None) -> tuple[Path, str]: def prepare_update(check: UpdateCheck, download_dir: Path | None = None) -> tuple[Path, str]:
"""Download + verify the tarball. Returns (tarball_path, version).""" """Download + verify the tarball. Returns (tarball_path, version)."""
if not check.tarball_url or not check.sha256_url: if not check.tarball_url or not check.sha256_url:
@ -369,15 +387,11 @@ def apply_update(tarball: Path, version: str) -> None:
target.chmod(0o755) target.chmod(0o755)
write_state("swapping", latest=version) write_state("swapping", latest=version)
previous = None previous = os.readlink(current) if current.is_symlink() else None
if current.is_symlink(): old_caddyfile = _CADDYFILE_LIVE.read_text() if _CADDYFILE_LIVE.is_file() else None
previous = os.readlink(current)
current.unlink()
try: try:
current.symlink_to(target) _swap_symlink(current, target)
except OSError as e: except OSError as e:
if previous:
current.symlink_to(previous)
raise UpdateError(f"symlink swap failed: {e}") from e raise UpdateError(f"symlink swap failed: {e}") from e
write_state("restarting", latest=version) write_state("restarting", latest=version)
@ -393,27 +407,41 @@ def apply_update(tarball: Path, version: str) -> None:
_run(["systemctl", "daemon-reload"]) _run(["systemctl", "daemon-reload"])
_run(["systemctl", "try-restart", "furtka-reconcile.service"]) _run(["systemctl", "try-restart", "furtka-reconcile.service"])
_run(["systemctl", "restart", "furtka-api.service"]) _run(["systemctl", "restart", "furtka-api.service"])
except UpdateError as e: except (UpdateError, OSError) as e:
_rollback(previous, version, f"service restart failed: {e}") # OSError: e.g. disk full or EACCES writing the Caddyfile — the new
raise # version is already live, so this must roll back too.
_rollback(previous, version, f"service restart failed: {e}", old_caddyfile)
raise UpdateError(f"service restart failed: {e} — rolled back") from e
write_state("verifying", latest=version) write_state("verifying", latest=version)
ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0) ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0)
if not ok: if not ok:
_rollback(previous, version, "health check failed after restart") _rollback(previous, version, "health check failed after restart", old_caddyfile)
raise UpdateError("new version failed health check — rolled back") raise UpdateError("new version failed health check — rolled back")
write_state("done", version=version) write_state("done", version=version)
def _rollback(previous_target: str | None, failed_version: str, reason: str) -> None: def _rollback(
previous_target: str | None,
failed_version: str,
reason: str,
old_caddyfile: str | None = None,
) -> None:
current = current_symlink() current = current_symlink()
if previous_target: if previous_target:
if current.is_symlink(): _swap_symlink(current, previous_target)
current.unlink() # The new release may have shipped Caddy routes the old code
current.symlink_to(previous_target) # doesn't serve; put the previous Caddyfile back before reloading.
if old_caddyfile is not None:
try:
_CADDYFILE_LIVE.write_text(old_caddyfile)
except OSError:
pass
# Best-effort restart on the previous target — if it fails too the # Best-effort restart on the previous target — if it fails too the
# box is in a hard state, but we can only surface the reason. # box is in a hard state, but we can only surface the reason.
subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "reload", "caddy"], check=False)
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False) subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
write_state( write_state(
"rolled_back", "rolled_back",
@ -430,13 +458,25 @@ def run_update() -> UpdateCheck:
UpdateError on any failure; the state file records the stage. UpdateError on any failure; the state file records the stage.
""" """
with acquire_lock(): with acquire_lock():
check = check_update() try:
if not check.update_available: check = check_update()
write_state("done", version=check.current, note="already up to date") if not check.update_available:
write_state("done", version=check.current, note="already up to date")
return check
tarball, version = prepare_update(check)
apply_update(tarball, version)
return check return check
tarball, version = prepare_update(check) except Exception as e:
apply_update(tarball, version) # Without a terminal stage the settings page keeps showing
return check # "Downloading…" forever. Rollback already wrote its own stage.
if read_state().get("stage") != "rolled_back":
try:
write_state("error", reason=str(e) or type(e).__name__)
except OSError:
pass
if isinstance(e, UpdateError):
raise
raise UpdateError(str(e) or type(e).__name__) from e
def rollback() -> str: def rollback() -> str:
@ -456,8 +496,7 @@ def rollback() -> str:
raise UpdateError("no other version slots available to roll back to") raise UpdateError("no other version slots available to roll back to")
target_name = candidates[0] target_name = candidates[0]
target = versions_dir() / target_name target = versions_dir() / target_name
current.unlink() _swap_symlink(current, target)
current.symlink_to(target)
subprocess.run(["systemctl", "daemon-reload"], check=False) subprocess.run(["systemctl", "daemon-reload"], check=False)
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False) subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
write_state("rolled_back_manual", restored_to=target_name) write_state("rolled_back_manual", restored_to=target_name)

View file

@ -30,6 +30,8 @@ new=$(
fi fi
echo echo
echo " Then follow the wizard to install Furtka on this machine." echo " Then follow the wizard to install Furtka on this machine."
echo " The install downloads packages, so this machine needs a network"
echo " cable with internet access. Wi-Fi is not set up automatically."
echo echo
echo " Need a shell? Log in as root (no password)." echo " Need a shell? Log in as root (no password)."
echo echo

View file

@ -407,7 +407,7 @@ def _request(port, path, cookie=None, method="GET", body=None):
def test_http_get_apps_route(fake_dirs, no_docker, admin_session): def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
"""Smoke test the actual HTTP server with a real socket, urllib client.""" """Smoke test the actual HTTP server with a real socket, urllib client."""
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -431,7 +431,7 @@ def test_http_get_apps_route(fake_dirs, no_docker, admin_session):
def test_http_post_install_unknown_app(fake_dirs, admin_session): def test_http_post_install_unknown_app(fake_dirs, admin_session):
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -459,7 +459,7 @@ def test_http_post_install_unknown_app(fake_dirs, admin_session):
def _start_server(): def _start_server():
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -980,7 +980,7 @@ def test_update_settings_unknown_app(fake_dirs):
def test_http_get_settings_route(fake_dirs, no_docker, admin_session): def test_http_get_settings_route(fake_dirs, no_docker, admin_session):
_, bundled = fake_dirs _, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1211,7 +1211,7 @@ def test_http_post_update_route(
api._do_install("fileshare") api._do_install("fileshare")
update_docker_stubs["up_called"] = 0 # reset counter after install update_docker_stubs["up_called"] = 0 # reset counter after install
update_docker_stubs["local"] = {"samba": "sha256:NEW"} update_docker_stubs["local"] = {"samba": "sha256:NEW"}
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1236,7 +1236,7 @@ def test_http_post_update_route(
def test_http_post_install_with_settings(fake_dirs, no_docker, no_systemd_run, admin_session): def test_http_post_install_with_settings(fake_dirs, no_docker, no_systemd_run, admin_session):
_, bundled = fake_dirs _, bundled = fake_dirs
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST) _write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
port = server.server_address[1] port = server.server_address[1]
t = threading.Thread(target=server.serve_forever, daemon=True) t = threading.Thread(target=server.serve_forever, daemon=True)
t.start() t.start()
@ -1424,3 +1424,103 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
status, body = api._do_power({"action": "reboot"}) status, body = api._do_power({"action": "reboot"})
assert status == 502 assert status == 502
assert "systemd-run" in body["error"] assert "systemd-run" in body["error"]
# --- hardening: names, body size, cross-site POSTs, crash handling ----------
@pytest.mark.parametrize("bad", ["/", ".", "..", "../catalog"])
def test_http_remove_rejects_path_names(fake_dirs, no_docker, admin_session, bad):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
server, port = _start_server()
try:
req = _request(
port, "/api/apps/remove", cookie=admin_session, method="POST", body={"name": bad}
)
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 400
assert (apps / "fileshare").exists()
finally:
server.shutdown()
server.server_close()
def test_http_post_rejects_oversized_body(fake_dirs, admin_session):
server, port = _start_server()
try:
import http.client
# Announce a huge body but never send it: the server must answer
# from the header alone instead of waiting to buffer it.
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5)
conn.putrequest("POST", "/api/apps/remove")
conn.putheader("Cookie", admin_session)
conn.putheader("Content-Length", str(api.MAX_BODY_BYTES + 1))
conn.endheaders()
assert conn.getresponse().status == 413
conn.close()
finally:
server.shutdown()
server.server_close()
def test_http_post_rejects_same_site_other_origin(fake_dirs, no_docker, admin_session):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
server, port = _start_server()
try:
req = _request(
port,
"/api/apps/remove",
cookie=admin_session,
method="POST",
body={"name": "fileshare"},
)
req.add_header("Sec-Fetch-Site", "same-site")
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 403
assert (apps / "fileshare").exists()
finally:
server.shutdown()
server.server_close()
def test_http_unexpected_exception_returns_json_500(fake_dirs, admin_session, monkeypatch):
def boom():
raise FileNotFoundError("docker")
monkeypatch.setattr(api, "_list_installed", boom)
server, port = _start_server()
try:
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(_request(port, "/api/apps", cookie=admin_session))
assert e.value.code == 500
assert "internal error" in json.loads(e.value.read())["error"]
finally:
server.shutdown()
server.server_close()
def test_login_with_corrupt_users_file_is_503_not_setup(fake_dirs, tmp_path):
(tmp_path / "users.json").write_text("")
server, port = _start_server()
try:
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(_request(port, "/login"))
assert e.value.code == 503
req = _request(
port,
"/login",
method="POST",
body={"username": "mallory", "password": "longenough1", "password2": "longenough1"},
)
with pytest.raises(urllib.error.HTTPError) as e:
urllib.request.urlopen(req)
assert e.value.code == 503
assert (tmp_path / "users.json").read_text() == ""
finally:
server.shutdown()
server.server_close()

View file

@ -226,3 +226,167 @@ def test_log_page_running_state_has_no_restart_button(monkeypatch, tmp_path):
html = app_module.app.test_client().get("/install/log").get_data(as_text=True) html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "next-steps" not in html assert "next-steps" not in html
assert "install_reboot" not in html and "restart now" not in html assert "install_reboot" not in html and "restart now" not in html
def test_nonzero_exit_without_traceback_is_an_error():
import app as app_module
# OOM kill / signal: archinstall dies mid-pacstrap and prints nothing.
log = "Installing packages: ['base'\n"
assert app_module.parse_install_progress(log)["status"] == "running"
assert app_module.parse_install_progress(log, exit_code=-9)["status"] == "error"
assert app_module.parse_install_progress(log, exit_code=0)["status"] == "running"
class _FakeProc:
def __init__(self, code=None):
self.code = code
def poll(self):
return self.code
def _install_env(monkeypatch, tmp_path):
import app as app_module
monkeypatch.setattr(app_module, "STATE_DIR", tmp_path)
monkeypatch.setattr(app_module, "INSTALL_LOG", tmp_path / "install.log")
monkeypatch.setattr(app_module, "_install_proc", None)
monkeypatch.delenv("FURTKA_DRY_RUN", raising=False)
monkeypatch.setitem(app_module.settings, "boot_drive", "/dev/sda")
monkeypatch.setitem(app_module.settings, "username", "u")
monkeypatch.setitem(app_module.settings, "password", "pw12345678")
monkeypatch.setattr(app_module, "release_disk", lambda d: [f"released {d}"])
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"device": d})
return app_module
def test_install_run_refuses_second_concurrent_install(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
spawned = []
monkeypatch.setattr(
app_module, "spawn_archinstall", lambda *a: spawned.append(a) or _FakeProc()
)
client = app_module.app.test_client()
assert client.post("/install/run").status_code == 302
assert client.post("/install/run").status_code == 302
assert len(spawned) == 1
def test_install_run_disk_probe_failure_lands_in_log(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
def boom(_):
raise RuntimeError("parted could not read /dev/sda")
monkeypatch.setattr(app_module, "build_disk_config", boom)
resp = app_module.app.test_client().post("/install/run")
assert resp.status_code == 302
log = (tmp_path / "install.log").read_text()
assert "Traceback (most recent call last)" in log
assert app_module.parse_install_progress(log)["status"] == "error"
def test_install_log_json_reports_crashed_archinstall(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Installing packages: ['base'\n")
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=137))
data = app_module.app.test_client().get("/install/log.json").get_json()
assert data["progress"]["status"] == "error"
assert "exit code 137" in data["log"]
def test_step2_rejects_disk_not_offered(monkeypatch):
import app as app_module
offered = [
{
"name": "/dev/sda",
"usable": True,
"size": "500 GB",
"type_label": "SSD",
"health_label": "Healthy",
"score": 27,
"reason": "",
"warning": "",
},
{
"name": "/dev/sdb",
"usable": False,
"size": "8 GB",
"type_label": "USB",
"health_label": "Status unknown",
"score": 10,
"reason": "Too small",
"warning": "",
},
]
monkeypatch.setattr(app_module, "list_scored_devices", lambda: offered)
monkeypatch.setitem(app_module.settings, "boot_drive", "")
client = app_module.app.test_client()
for bad in ("/dev/sdb", "/dev/nope", "/etc/passwd"):
resp = client.post("/install/step2", data={"boot_drive": bad})
assert resp.status_code == 200
assert app_module.settings["boot_drive"] == ""
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
assert resp.status_code == 302
assert app_module.settings["boot_drive"] == "/dev/sda"
def test_validate_step1_lowercases_names():
errors, values = validate_step1(
{
"hostname": "Furtka",
"username": "Daniel",
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "de",
}
)
assert errors == []
assert values["hostname"] == "furtka"
assert values["username"] == "daniel"
def test_validate_step1_rejects_reserved_usernames():
for name in ("root", "caddy", "docker", "nobody"):
errors, _ = validate_step1(
{
"hostname": "furtka",
"username": name,
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "en",
}
)
assert len(errors) == 1 and "reserved" in errors[0], name
def test_post_install_sets_wait_online_any(monkeypatch):
import base64
import app as app_module
cmds = app_module._post_install_commands("h", "u", "pw12345678")
hits = [c for c in cmds if "systemd-networkd-wait-online.service.d" in c]
assert len(hits) == 1
payload = hits[0].split("printf %s ")[1].split(" |")[0]
assert "--any" in base64.b64decode(payload).decode()
def test_wizard_pages_redirect_to_progress_while_installing(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=None))
client = app_module.app.test_client()
for path in ("/install/step1", "/install/step2", "/install/overview"):
resp = client.get(path)
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/install/log")
def test_error_page_offers_retry(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Traceback (most recent call last)\nboom\n")
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" in html
assert "next-steps" not in html

View file

@ -40,14 +40,15 @@ def test_load_users_returns_empty_when_missing(tmp_users_file):
assert auth.load_users() == {} assert auth.load_users() == {}
def test_load_users_returns_empty_on_junk(tmp_users_file): @pytest.mark.parametrize("content", ["{not json", "[]", "", "\x00\x00"])
tmp_users_file.write_text("{not json") def test_load_users_fails_closed_on_corrupt_file(tmp_users_file, content):
assert auth.load_users() == {} """A damaged users.json must not read as "no admin yet" — that would
turn /login into an open create-admin form for anyone on the LAN."""
tmp_users_file.write_text(content)
def test_load_users_returns_empty_on_non_dict(tmp_users_file): with pytest.raises(auth.UsersFileError):
tmp_users_file.write_text("[]") auth.load_users()
assert auth.load_users() == {} with pytest.raises(auth.UsersFileError):
auth.setup_needed()
def test_save_users_atomic_and_0600(tmp_users_file): def test_save_users_atomic_and_0600(tmp_users_file):

View file

@ -349,3 +349,30 @@ def test_sync_catalog_failure_writes_error_state(catalog, monkeypatch):
state = catalog.read_state() state = catalog.read_state()
assert state["stage"] == "error" assert state["stage"] == "error"
assert "Network is unreachable" in state["error"] assert "Network is unreachable" in state["error"]
def test_sync_catalog_unexpected_error_lands_as_error_stage(catalog, monkeypatch):
"""Disk full / mid-body disconnect used to leave the stage at
'downloading' forever because only CatalogError was caught."""
_stub_forgejo_release(monkeypatch, catalog, tag="26.6")
def boom(*a, **kw):
raise OSError(28, "No space left on device")
monkeypatch.setattr(catalog._rc, "download", boom)
with pytest.raises(catalog.CatalogError, match="No space left"):
catalog.sync_catalog()
assert catalog.read_state()["stage"] == "error"
def test_release_download_wraps_socket_errors(tmp_path, monkeypatch):
from furtka import _release_common as rc
def reset(*a, **kw):
raise ConnectionResetError("reset by peer")
monkeypatch.setattr(rc.urllib.request, "urlopen", reset)
with pytest.raises(rc.ReleaseError, match="reset by peer"):
rc.download("https://x/y", tmp_path / "y")
with pytest.raises(rc.ReleaseError, match="reset by peer"):
rc.forgejo_api("x", "a/b", "/releases")

View file

@ -1,47 +1,63 @@
import json
import drives
import pytest
from drives import ( from drives import (
get_drive_type_label, MIN_DISK_GB,
_boot_device_from_cmdline,
classify_drive,
format_size,
get_drive_type_score, get_drive_type_score,
get_size_score, get_size_score,
parse_lsblk_output, parse_lsblk_json,
parse_size_gb,
score_device,
) )
GB = 1024**3
def test_parse_size_gb_terabytes():
assert parse_size_gb("1T") == 1024.0
def test_parse_size_gb_gigabytes(): def _lsblk(*entries):
assert parse_size_gb("500G") == 500.0 """Build lsblk -J output. Each entry: (name, size_gb, type, extra-dict)."""
devs = []
for name, size_gb, dev_type, *extra in entries:
d = {
"name": name,
"size": int(size_gb * GB),
"type": dev_type,
"ro": False,
"rm": False,
"tran": "sata",
"rota": True,
}
if extra:
d.update(extra[0])
devs.append(d)
return json.dumps({"blockdevices": devs})
def test_parse_size_gb_megabytes(): @pytest.fixture(autouse=True)
assert parse_size_gb("2048M") == 2.0 def _smart_passed(monkeypatch):
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
def test_parse_size_gb_european_comma_decimal(): def _names(devices):
assert parse_size_gb("1,5T") == 1.5 * 1024 return [d["name"] for d in devices]
def test_parse_size_gb_empty_returns_none(): def test_classify_uses_transport_and_rotation():
assert parse_size_gb("") is None assert classify_drive("nvme0n1", tran="nvme", rota=False) == "NVMe"
assert classify_drive("sda", tran="sata", rota=False) == "SSD"
assert classify_drive("sda", tran="sata", rota=True) == "HDD"
assert classify_drive("mmcblk0", tran="", rota=False) == "eMMC"
assert classify_drive("sdb", tran="usb", rota=False) == "USB"
assert classify_drive("sdc", tran="", rota=True, removable=True) == "USB"
# SD cards in a built-in reader are removable mmc — still eMMC/mmc class.
assert classify_drive("mmcblk1", tran="", rota=False, removable=True) == "eMMC"
def test_parse_size_gb_unknown_unit_returns_none(): def test_type_scores_rank_internal_over_usb():
assert parse_size_gb("500K") is None assert get_drive_type_score("NVMe") > get_drive_type_score("SSD")
assert get_drive_type_score("SSD") > get_drive_type_score("HDD")
assert get_drive_type_score("HDD") > get_drive_type_score("USB")
def test_drive_type_score_nvme():
assert get_drive_type_score("/dev/nvme0n1") == 15
def test_drive_type_score_ssd():
assert get_drive_type_score("/dev/ssd0") == 10
def test_drive_type_score_hdd_fallback():
assert get_drive_type_score("/dev/sda") == 5
def test_size_score_bands(): def test_size_score_bands():
@ -51,67 +67,145 @@ def test_size_score_bands():
assert get_size_score(1024) == 10 assert get_size_score(1024) == 10
def test_score_device_sums_type_and_size(monkeypatch): def test_format_size():
import drives assert format_size(500 * GB) == "500 GB"
assert format_size(1024 * GB) == "1 TB"
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10) assert format_size(int(1.5 * 1024 * GB)) == "1.5 TB"
assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10 assert format_size(int(7.5 * GB)) == "7.5 GB"
assert score_device("/dev/sda", 64) == 5 + 10 + 5
def test_parse_lsblk_drops_loop_and_rom(monkeypatch): def test_drops_loop_rom_zram_and_emmc_boot_areas():
import drives out = _lsblk(
("loop0", 2.5, "loop"),
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") ("sr0", 1, "rom"),
output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n" ("zram0", 4, "disk"),
devices = parse_lsblk_output(output) ("mmcblk0boot0", 0.004, "disk"),
names = [d["name"] for d in devices] ("mmcblk0boot1", 0.004, "disk"),
assert names == ["/dev/nvme0n1", "/dev/sda"] ("mmcblk0rpmb", 0.004, "disk"),
("mmcblk0", 64, "disk", {"tran": "", "rota": False}),
)
assert _names(parse_lsblk_json(out)) == ["/dev/mmcblk0"]
def test_parse_lsblk_attaches_human_labels(monkeypatch): def test_drops_empty_card_reader_slots_and_read_only_disks():
import drives out = _lsblk(
("sdc", 0, "disk", {"rm": True, "tran": "usb"}),
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") ("sdd", 500, "disk", {"ro": True}),
output = "nvme0n1 1T disk\n" ("sda", 500, "disk"),
[dev] = parse_lsblk_output(output) )
assert dev["type_label"] == "NVMe" assert _names(parse_lsblk_json(out)) == ["/dev/sda"]
assert dev["health_label"] == "Healthy"
def test_parse_lsblk_surfaces_smart_warning(monkeypatch): def test_ssd_beats_bigger_hdd():
import drives # Regression: sdX SSDs used to be labelled HDD, so the 2 TB spinning disk
# won the recommendation over the 256 GB SSD.
out = _lsblk(
("sda", 2048, "disk", {"rota": True}),
("sdb", 256, "disk", {"rota": False}),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
assert [d["type_label"] for d in devices] == ["SSD", "HDD"]
def test_usb_disk_ranked_after_internal():
out = _lsblk(
("sda", 32, "disk", {"tran": "usb", "rota": False, "rm": True}),
("mmcblk0", 32, "disk", {"tran": "", "rota": False}),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/mmcblk0", "/dev/sda"]
assert devices[1]["warning"]
def test_too_small_disk_listed_but_unusable_and_last():
out = _lsblk(
("sda", MIN_DISK_GB - 1, "disk", {"rota": False}),
("sdb", 500, "disk"),
)
devices = parse_lsblk_json(out)
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
assert devices[0]["usable"] is True
assert devices[1]["usable"] is False
assert str(MIN_DISK_GB) in devices[1]["reason"]
def test_bios_warns_about_disks_over_2tb():
out = _lsblk(("sda", 4096, "disk"))
[bios] = parse_lsblk_json(out, uefi=False)
[efi] = parse_lsblk_json(out, uefi=True)
assert "2 TB" in bios["warning"]
assert efi["warning"] == ""
def test_surfaces_smart_warning(monkeypatch):
monkeypatch.setattr(drives, "_smart_status", lambda _: "failed") monkeypatch.setattr(drives, "_smart_status", lambda _: "failed")
[dev] = parse_lsblk_output("sda 500G disk\n") [dev] = parse_lsblk_json(_lsblk(("sda", 500, "disk")))
assert dev["health_label"] == "SMART warning" assert dev["health_label"] == "SMART warning"
def test_drive_type_label_nvme_ssd_hdd(): def test_accepts_old_util_linux_string_flags():
assert get_drive_type_label("/dev/nvme0n1") == "NVMe" out = json.dumps(
assert get_drive_type_label("/dev/ssd0") == "SSD" {
assert get_drive_type_label("/dev/sda") == "HDD" "blockdevices": [
{
"name": "sda",
"size": str(500 * GB),
"type": "disk",
"ro": "0",
"rm": "0",
"tran": "sata",
"rota": "0",
},
]
}
)
[dev] = parse_lsblk_json(out)
assert dev["type_label"] == "SSD"
def test_parse_lsblk_handles_empty_output(): def test_handles_empty_and_garbage_output():
assert parse_lsblk_output("") == [] assert parse_lsblk_json("") == []
assert parse_lsblk_json("not json") == []
def test_parse_lsblk_drops_boot_usb(monkeypatch): def test_drops_boot_usb():
import drives out = _lsblk(
("sda", 500, "disk"),
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") ("sdb", 16, "disk", {"tran": "usb"}),
output = "sda 500G disk\nsdb 16G disk\nnvme0n1 1T disk\n" ("nvme0n1", 1024, "disk", {"tran": "nvme", "rota": False}),
devices = parse_lsblk_output(output, boot_disk="sdb") )
names = [d["name"] for d in devices] assert _names(parse_lsblk_json(out, boot_disk="sdb")) == ["/dev/nvme0n1", "/dev/sda"]
assert "/dev/sdb" not in names
assert names == ["/dev/nvme0n1", "/dev/sda"]
def test_parse_lsblk_no_boot_disk_keeps_all(monkeypatch): def test_boot_device_from_cmdline(monkeypatch):
import drives calls = []
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed") class R:
output = "sda 500G disk\nsdb 16G disk\n" returncode = 0
names = [d["name"] for d in parse_lsblk_output(output, boot_disk=None)] stdout = "/dev/sdb1\n"
assert set(names) == {"/dev/sda", "/dev/sdb"}
monkeypatch.setattr(drives, "_run", lambda cmd, **_: calls.append(cmd) or R())
cmdline = "BOOT_IMAGE=/vmlinuz archisosearchuuid=2026-08-25-10-00-00-00 quiet"
assert _boot_device_from_cmdline(cmdline) == "/dev/sdb1"
assert calls[-1] == ["blkid", "-U", "2026-08-25-10-00-00-00"]
assert _boot_device_from_cmdline("archisolabel=FURTKA_2620") == "/dev/sdb1"
assert calls[-1] == ["blkid", "-L", "FURTKA_2620"]
assert _boot_device_from_cmdline("archisodevice=/dev/sr0") == "/dev/sr0"
assert _boot_device_from_cmdline("quiet splash") is None
def test_boot_disk_whole_disk_iso(monkeypatch):
# dd-written stick: ISO filesystem on the whole disk, PKNAME empty.
class R:
def __init__(self, out):
self.returncode = 0
self.stdout = out
def fake_run(cmd, **_):
if cmd[0] == "findmnt":
return R("/dev/sdb\n")
return R("\n")
monkeypatch.setattr(drives, "_run", fake_run)
assert drives._boot_disk_name() == "sdb"

View file

@ -478,3 +478,35 @@ def test_parse_hook_output_furtka_json_rejects_bad_payload(runner):
def test_parse_hook_output_furtka_json_invalid_json(runner): def test_parse_hook_output_furtka_json_invalid_json(runner):
with pytest.raises(runner.InstallRunnerError, match="invalid FURTKA_JSON"): with pytest.raises(runner.InstallRunnerError, match="invalid FURTKA_JSON"):
runner._parse_hook_output("FURTKA_JSON: {not json}\n") runner._parse_hook_output("FURTKA_JSON: {not json}\n")
def test_clear_stale_state_resets_abandoned_install(runner):
import time
runner.write_state("pulling_image", app="fileshare")
# Fresh state: could still be the runner starting up — leave it alone.
assert runner.clear_stale_state() is False
assert runner.read_state()["stage"] == "pulling_image"
# Two minutes later with nobody holding the lock: interrupted.
assert runner.clear_stale_state(now=time.time() + 120) is True
state = runner.read_state()
assert state["stage"] == "error"
assert state["app"] == "fileshare"
assert "interrupted" in state["error"]
def test_clear_stale_state_keeps_running_install(runner):
import time
runner.write_state("pulling_image", app="fileshare")
fh = runner.acquire_lock()
try:
assert runner.clear_stale_state(now=time.time() + 3600) is False
assert runner.read_state()["stage"] == "pulling_image"
finally:
fh.close()
def test_read_state_tolerates_non_object(runner):
runner.state_path().write_text("[1, 2]")
assert runner.read_state() == {}

View file

@ -462,3 +462,58 @@ def test_install_plan_skips_already_installed(tmp_path, fake_dirs, monkeypatch):
) )
installer.install_plan(plan) installer.install_plan(plan)
assert calls == ["a"] assert calls == ["a"]
# --- hardening: app names, validate-before-write, .env quoting --------------
@pytest.mark.parametrize("bad", ["/", ".", "..", "../catalog", "a/b", "", "UPPER"])
def test_remove_refuses_non_app_names(fake_dirs, bad):
apps, _ = fake_dirs
(apps / "fileshare").mkdir()
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.remove(bad)
assert (apps / "fileshare").exists()
assert apps.exists()
def test_update_env_refuses_non_app_names(fake_dirs):
with pytest.raises(installer.InstallError, match="not a valid app name"):
installer.update_env("..", {"X": "1"})
def test_rejected_path_setting_never_reaches_env(tmp_path, fake_dirs):
media = tmp_path / "media"
media.mkdir()
src = _write_app_source(tmp_path, "jellyfin", PATH_MANIFEST)
installer.install_from(src, settings={"MEDIA_PATH": str(media)})
env = apps_dir() / "jellyfin" / ".env"
with pytest.raises(installer.InstallError, match="system path"):
installer.update_env("jellyfin", {"MEDIA_PATH": "/etc"})
assert "/etc" not in env.read_text()
assert f"MEDIA_PATH={media}" in env.read_text()
def test_newline_in_setting_is_rejected(tmp_path, fake_dirs):
m = dict(VALID_MANIFEST, settings=[{"name": "PW", "label": "PW", "type": "password"}])
src = _write_app_source(tmp_path, "fileshare", m)
with pytest.raises(installer.InstallError, match="line breaks"):
installer.install_from(src, settings={"PW": "abc\nCOMPOSE_PROJECT_NAME=x"})
env = apps_dir() / "fileshare" / ".env"
assert not env.exists() or "COMPOSE_PROJECT_NAME" not in env.read_text()
def test_dollar_values_are_single_quoted_and_round_trip(tmp_path):
env = tmp_path / ".env"
values = {"PW": "pa$word", "B": "has space", "C": "it's $5", "D": "plain"}
installer.write_env(env, values)
text = env.read_text()
assert "PW='pa$word'" in text
assert "D=plain" in text
assert installer.read_env_values(env) == values
def test_manifest_name_must_be_app_name(tmp_path, fake_dirs):
src = _write_app_source(tmp_path, "evil", dict(VALID_MANIFEST, name="../evil"))
with pytest.raises(installer.InstallError, match="lowercase"):
installer.install_from(src)

View file

@ -463,3 +463,79 @@ def test_check_update_raises_when_no_releases_published(updater, monkeypatch):
monkeypatch.setattr(updater, "_forgejo_api", lambda path: []) monkeypatch.setattr(updater, "_forgejo_api", lambda path: [])
with pytest.raises(updater.UpdateError, match="no releases"): with pytest.raises(updater.UpdateError, match="no releases"):
updater.check_update() updater.check_update()
def test_apply_update_rolls_back_on_oserror_and_restores_caddyfile(tmp_path, updater, monkeypatch):
"""A non-UpdateError after the symlink flip (disk full writing the
Caddyfile) used to leave the new version live with no rollback."""
versions = updater.versions_dir()
versions.mkdir(parents=True)
(versions / "26.0-alpha").mkdir()
current = updater.current_symlink()
current.symlink_to(versions / "26.0-alpha")
caddyfile = tmp_path / "etc_caddy" / "Caddyfile"
caddyfile.parent.mkdir()
caddyfile.write_text("# old routes\n")
tar = tmp_path / "t.tar.gz"
_make_tarball(tar, "26.1-alpha")
def full_disk(_source):
caddyfile.write_text("# half-written new routes\n")
raise OSError(28, "No space left on device")
monkeypatch.setattr(updater, "_refresh_caddyfile", full_disk)
monkeypatch.setattr(updater, "_run", lambda cmd: None)
import subprocess
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: None)
with pytest.raises(updater.UpdateError, match="rolled back"):
updater.apply_update(tar, "26.1-alpha")
assert current.resolve() == (versions / "26.0-alpha").resolve()
assert caddyfile.read_text() == "# old routes\n"
assert updater.read_state()["stage"] == "rolled_back"
def test_swap_symlink_replaces_existing_link(tmp_path, updater):
a, b = tmp_path / "a", tmp_path / "b"
a.mkdir()
b.mkdir()
link = tmp_path / "current"
link.symlink_to(a)
updater._swap_symlink(link, b)
assert link.resolve() == b.resolve()
assert not (tmp_path / "current.new").exists()
def test_run_update_writes_error_stage_on_download_failure(updater, monkeypatch):
class _Check:
update_available = True
latest = "26.1-alpha"
current = "26.0-alpha"
tarball_url = "http://x/t.tar.gz"
sha256_url = "http://x/t.sha256"
def fake_check():
return _Check()
monkeypatch.setattr(updater, "check_update", fake_check)
def boom(url, dest):
raise OSError(28, "No space left on device")
monkeypatch.setattr(updater, "_download", boom)
with pytest.raises(updater.UpdateError, match="No space left"):
updater.run_update()
state = updater.read_state()
assert state["stage"] == "error"
assert "No space left" in state["reason"]
def test_health_check_survives_read_timeout(updater, monkeypatch):
def slow(url, timeout):
raise TimeoutError("timed out")
monkeypatch.setattr("urllib.request.urlopen", slow)
monkeypatch.setattr(updater.time, "sleep", lambda _s: None)
assert updater._health_check("http://127.0.0.1:7000/api/apps", deadline_s=0.05) is False

View file

@ -8,6 +8,8 @@ import os
import re import re
import subprocess import subprocess
import sys import sys
import threading
import traceback
from datetime import UTC from datetime import UTC
from pathlib import Path from pathlib import Path
@ -76,6 +78,35 @@ settings = {
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$") HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$") USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
# Accounts that already exist on the installed system (base + the packages
# we install). Picking one of these either fails user creation mid-install
# (root) or makes the admin collide with a service account (caddy, docker…).
RESERVED_USERNAMES = frozenset(
{
"root",
"bin",
"daemon",
"mail",
"ftp",
"http",
"nobody",
"dbus",
"git",
"uuidd",
"avahi",
"caddy",
"docker",
"polkitd",
"rtkit",
"systemd-coredump",
"systemd-journal-remote",
"systemd-network",
"systemd-oom",
"systemd-resolve",
"systemd-timesync",
}
)
# Ordered phase markers for the install progress bar. Each tuple is # Ordered phase markers for the install progress bar. Each tuple is
# (substring to search for in the archinstall log, progress percent when # (substring to search for in the archinstall log, progress percent when
# reached, user-facing label). Pick the furthest phase whose marker is # reached, user-facing label). Pick the furthest phase whose marker is
@ -108,7 +139,13 @@ PROGRESS_ERROR_HINTS = [
] ]
def parse_install_progress(log): def parse_install_progress(log, exit_code=None):
"""Progress dict from the install log.
`exit_code` is archinstall's return code once the process has ended
(None while running or unknown). A non-zero exit is an error even when
the log has no Traceback — OOM kills and signals print nothing.
"""
percent = 2 percent = 2
phase = "Starting up…" phase = "Starting up…"
for marker, pct, label in PROGRESS_PHASES: for marker, pct, label in PROGRESS_PHASES:
@ -118,7 +155,7 @@ def parse_install_progress(log):
if percent >= 100: if percent >= 100:
status = "done" status = "done"
elif any(m in log for m in PROGRESS_ERROR_MARKERS): elif any(m in log for m in PROGRESS_ERROR_MARKERS) or exit_code not in (None, 0):
status = "error" status = "error"
phase = "Installation failed — open Show details below" phase = "Installation failed — open Show details below"
for marker, hint in PROGRESS_ERROR_HINTS: for marker, hint in PROGRESS_ERROR_HINTS:
@ -127,24 +164,39 @@ def parse_install_progress(log):
break break
else: else:
status = "running" status = "running"
if percent == 18:
# archinstall waits for NTP time sync with no timeout. Only a hint:
# it usually clears on its own within a minute.
phase = (
"Syncing time and packages — if this takes more than a few "
"minutes, check that the network cable is plugged in"
)
return {"percent": percent, "phase": phase, "status": status} return {"percent": percent, "phase": phase, "status": status}
def validate_step1(form): def validate_step1(form):
errors = [] errors = []
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
# lowercase, people type names the way they write them.
values = { values = {
"hostname": form.get("hostname", "").strip(), "hostname": form.get("hostname", "").strip().lower(),
"username": form.get("username", "").strip(), "username": form.get("username", "").strip().lower(),
"password": form.get("password", ""), "password": form.get("password", ""),
"language": form.get("language", ""), "language": form.get("language", ""),
} }
password2 = form.get("password2", "") password2 = form.get("password2", "")
if not HOSTNAME_RE.match(values["hostname"]): if not HOSTNAME_RE.match(values["hostname"]):
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).") errors.append(
"The server name can only use letters, digits and hyphens (no spaces, max 63)."
)
if not USERNAME_RE.match(values["username"]): if not USERNAME_RE.match(values["username"]):
errors.append("Username must start with a letter or underscore, lowercase only.") errors.append(
"The username must start with a letter and can only use letters, digits, - and _."
)
elif values["username"] in RESERVED_USERNAMES:
errors.append(f"“{values['username']}” is reserved by the system — please pick another.")
if len(values["password"]) < 8: if len(values["password"]) < 8:
errors.append("Password must be at least 8 characters.") errors.append("Password must be at least 8 characters.")
if values["password"] != password2: if values["password"] != password2:
@ -438,6 +490,16 @@ def _post_install_commands(hostname, admin_username, admin_password):
# Initial status.json so Caddy doesn't 404 before furtka-status fires. # Initial status.json so Caddy doesn't 404 before furtka-status fires.
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")), _write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
nss_sed, nss_sed,
# The installed system inherits the live ISO's networkd config
# (DHCP on every Ethernet + Wi-Fi link) but not its wait-online
# drop-in, so a board with a second, unplugged NIC stalled boot for
# up to 2 minutes before network-online.target. Any one link is
# enough for a home server.
_write_file_cmd(
"/etc/systemd/system/systemd-networkd-wait-online.service.d/any-interface.conf",
"[Service]\nExecStart=\n"
"ExecStart=/usr/lib/systemd/systemd-networkd-wait-online --any\n",
),
# Resource manager bootstrap: extract tarball → versions/<ver>/, # Resource manager bootstrap: extract tarball → versions/<ver>/,
# symlink current, install wrapper, systemctl-link unit files. # symlink current, install wrapper, systemctl-link unit files.
*_resource_manager_commands(), *_resource_manager_commands(),
@ -570,6 +632,16 @@ def spawn_archinstall(config_path, creds_path, log_path):
) )
@app.before_request
def _stay_on_progress_while_installing():
# While archinstall runs, the wizard pages would let a second browser
# (or Back) edit settings that no longer apply — show the progress.
running = _install_proc is not None and _install_proc.poll() is None
if running and request.endpoint in ("install_step_1", "install_step_2", "install_overview"):
return redirect(url_for("install_log_view"))
return None
@app.route("/") @app.route("/")
def home(): def home():
return redirect(url_for("install_step_1")) return redirect(url_for("install_step_1"))
@ -594,32 +666,89 @@ def install_step_1():
) )
# Size/type of the disk picked in step 2, for the confirm page (re-probing
# would re-run smartctl on every disk).
_selected_drive: dict = {}
@app.route("/install/step2", methods=["GET", "POST"]) @app.route("/install/step2", methods=["GET", "POST"])
def install_step_2(): def install_step_2():
drives = list_scored_devices()
error = ""
if request.method == "POST": if request.method == "POST":
boot_drive = request.form.get("boot_drive", "").strip() boot_drive = request.form.get("boot_drive", "").strip()
if boot_drive: # Only accept a disk we actually offered — never a free-form path.
picked = next((d for d in drives if d["name"] == boot_drive and d["usable"]), None)
if picked:
settings["boot_drive"] = boot_drive settings["boot_drive"] = boot_drive
_selected_drive.clear()
_selected_drive.update(picked)
return redirect(url_for("install_overview")) return redirect(url_for("install_overview"))
error = "Please pick one of the disks below."
return render_template( return render_template(
"install/step2.html", "install/step2.html",
drives=list_scored_devices(), drives=drives,
selected=settings.get("boot_drive", ""), selected=settings.get("boot_drive", ""),
error=error,
) )
@app.route("/install/overview") @app.route("/install/overview")
def install_overview(): def install_overview():
if not settings["boot_drive"] or not settings["username"]:
return redirect(url_for("install_step_1"))
masked = {**settings, "password": "•" * 8 if settings["password"] else ""} masked = {**settings, "password": "•" * 8 if settings["password"] else ""}
return render_template("install/overview.html", settings=masked) drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None
language = LANGUAGES.get(settings["language"], {}).get("label", settings["language"])
return render_template(
"install/overview.html", settings=masked, drive=drive, language_label=language
)
# The running archinstall process, so a second click (double-submit, Back +
# Install again, a second browser) can't start another install on the same
# disk, and so the progress page can see an exit without a Traceback.
_install_proc = None
_install_lock = threading.Lock()
def _install_exit_code():
return None if _install_proc is None else _install_proc.poll()
def _read_install_log():
log = INSTALL_LOG.read_text(errors="replace") if INSTALL_LOG.exists() else ""
code = _install_exit_code()
if code not in (None, 0):
log += f"\n\narchinstall stopped unexpectedly (exit code {code}).\n"
return log, code
@app.route("/install/run", methods=["POST"]) @app.route("/install/run", methods=["POST"])
def install_run(): def install_run():
if not settings["boot_drive"] or not settings["username"] or not settings["password"]: if not settings["boot_drive"] or not settings["username"] or not settings["password"]:
return redirect(url_for("install_step_1")) return redirect(url_for("install_step_1"))
config_path, creds_path = write_install_files(settings, STATE_DIR) with _install_lock:
if _install_proc is not None and _install_proc.poll() is None:
return redirect(url_for("install_log_view"))
return _start_install()
def _start_install():
global _install_proc
STATE_DIR.mkdir(parents=True, exist_ok=True)
INSTALL_LOG.write_bytes(b"") INSTALL_LOG.write_bytes(b"")
try:
config_path, creds_path = write_install_files(settings, STATE_DIR)
except Exception:
# archinstall's disk probe raises on disks it can't parse (broken
# GPT, odd card readers). Put the traceback where the progress page
# finds it instead of a bare "Internal Server Error".
with INSTALL_LOG.open("a") as fh:
fh.write(
f"Could not read the layout of {settings['boot_drive']}.\n" + traceback.format_exc()
)
return redirect(url_for("install_log_view"))
if os.environ.get("FURTKA_DRY_RUN") == "1": if os.environ.get("FURTKA_DRY_RUN") == "1":
INSTALL_LOG.write_text( INSTALL_LOG.write_text(
f"DRY RUN: would exec archinstall --config {config_path} " f"DRY RUN: would exec archinstall --config {config_path} "
@ -631,33 +760,33 @@ def install_run():
# while those hold the partitions open (first hardware bench, 26.19). # while those hold the partitions open (first hardware bench, 26.19).
with INSTALL_LOG.open("a") as fh: with INSTALL_LOG.open("a") as fh:
fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n") fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n")
spawn_archinstall(config_path, creds_path, INSTALL_LOG) _install_proc = spawn_archinstall(config_path, creds_path, INSTALL_LOG)
return redirect(url_for("install_log_view")) return redirect(url_for("install_log_view"))
@app.route("/install/log") @app.route("/install/log")
def install_log_view(): def install_log_view():
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "" log, code = _read_install_log()
return render_template( return render_template(
"install/log.html", "install/log.html",
log=log, log=log,
progress=parse_install_progress(log), progress=parse_install_progress(log, code),
hostname=settings["hostname"], hostname=settings["hostname"],
) )
@app.route("/install/log.json") @app.route("/install/log.json")
def install_log_json(): def install_log_json():
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "" log, code = _read_install_log()
return jsonify(log=log, progress=parse_install_progress(log)) return jsonify(log=log, progress=parse_install_progress(log, code))
@app.route("/install/reboot", methods=["POST"]) @app.route("/install/reboot", methods=["POST"])
def install_reboot(): def install_reboot():
# Only allow rebooting once the install has actually finished — we don't # Only allow rebooting once the install has actually finished — we don't
# want a panicked click during install to reboot mid-pacstrap. # want a panicked click during install to reboot mid-pacstrap.
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else "" log, code = _read_install_log()
if parse_install_progress(log)["status"] != "done": if parse_install_progress(log, code)["status"] != "done":
return redirect(url_for("install_log_view")) return redirect(url_for("install_log_view"))
# Delay reboot a few seconds so the browser can finish fetching CSS / assets # Delay reboot a few seconds so the browser can finish fetching CSS / assets
# for the rebooting page before the Flask server (and network) go away. # for the rebooting page before the Flask server (and network) go away.

View file

@ -1,56 +1,90 @@
import json
import re
import subprocess import subprocess
from pathlib import Path
# Smallest disk we offer as an install target. The base system plus Docker
# needs ~6 GB; the rest is headroom for images and app data.
MIN_DISK_GB = 16
# MBR (what archinstall lays out on BIOS boxes) can't address past 2 TiB.
MBR_LIMIT_GB = 2048
# Block devices lsblk reports as TYPE=disk that are never install targets:
# compressed RAM swap and the eMMC hardware boot / RPMB areas (4 MB, usually
# read-only) that show up next to mmcblk0 on every eMMC mini-PC and laptop.
_PSEUDO_DISK_RE = re.compile(r"^(zram\d+|mmcblk\d+boot\d+|mmcblk\d+rpmb)$")
_SUBPROCESS_TIMEOUT = 15
def _run(cmd, timeout=_SUBPROCESS_TIMEOUT):
"""subprocess.run with a timeout; None when the tool is missing or hangs."""
try:
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _parent_disk(device):
result = _run(["lsblk", "-no", "PKNAME", device])
if result is None or result.returncode != 0:
return None
lines = [line.strip() for line in result.stdout.splitlines() if line.strip()]
return lines[0] if lines else None
def _boot_device_from_cmdline(cmdline):
"""Resolve the boot partition from archiso's kernel args, or None."""
args = dict(arg.split("=", 1) for arg in cmdline.split() if "=" in arg)
if args.get("archisodevice", "").startswith("/dev/"):
return args["archisodevice"]
if args.get("archisosearchuuid"):
cmd = ["blkid", "-U", args["archisosearchuuid"]]
elif args.get("archisolabel"):
cmd = ["blkid", "-L", args["archisolabel"]]
else:
return None
result = _run(cmd)
if result is None or result.returncode != 0:
return None
return result.stdout.strip() or None
def _boot_disk_name(): def _boot_disk_name():
"""Return the parent disk name of the live-ISO boot media (e.g. "sdb"), or None. """Return the parent disk name of the live-ISO boot media (e.g. "sdb"), or None.
On a normal box `/run/archiso/bootmnt` does not exist and we return None, On bare metal booted from USB this is the stick we booted from — we want
leaving the device list untouched. On bare metal booted from USB this is to filter it out so the user can't accidentally pick it as the install
the stick we booted from — we want to filter it out so the user can't target. Primary source is the mount behind /run/archiso/bootmnt; when
accidentally pick it as the install target. that's gone (copytoram unmounts it) fall back to the UUID/label archiso
put on the kernel command line.
""" """
try: partition = None
result = subprocess.run( result = _run(["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"])
["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"], if result is not None and result.returncode == 0:
capture_output=True, partition = result.stdout.strip() or None
text=True, if partition is None:
) try:
except FileNotFoundError: partition = _boot_device_from_cmdline(Path("/proc/cmdline").read_text())
except OSError:
partition = None
if partition is None:
return None return None
if result.returncode != 0: # A stick written with dd has the ISO filesystem on the whole disk, where
return None # PKNAME is empty — the device itself is then the disk.
partition = result.stdout.strip() return _parent_disk(partition) or Path(partition).name
if not partition:
return None
try:
parent = subprocess.run(
["lsblk", "-no", "PKNAME", partition],
capture_output=True,
text=True,
)
except FileNotFoundError:
return None
if parent.returncode != 0:
return None
name = parent.stdout.strip().splitlines()[0] if parent.stdout.strip() else ""
return name or None
def _smart_status(device): def _smart_status(device):
try: result = _run(["smartctl", "-H", device])
result = subprocess.run( if result is None:
["smartctl", "-H", device],
capture_output=True,
)
output = result.stdout.decode()
if "PASSED" in output:
return "passed"
elif "FAILED" in output:
return "failed"
return "unknown"
except Exception as e:
print(f"Error checking SMART status for {device}: {e}")
return "unknown" return "unknown"
if "PASSED" in result.stdout:
return "passed"
if "FAILED" in result.stdout:
return "failed"
return "unknown"
_HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5} _HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5}
@ -60,40 +94,33 @@ _HEALTH_LABEL = {
"unknown": "Status unknown", "unknown": "Status unknown",
} }
_TYPE_SCORE = {"NVMe": 15, "SSD": 10, "eMMC": 8, "HDD": 5, "USB": 0}
def get_drive_health(device): def get_drive_health(device):
return _HEALTH_SCORE[_smart_status(device)] return _HEALTH_SCORE[_smart_status(device)]
def get_drive_type_score(device): def classify_drive(name, tran="", rota=None, removable=False):
name = device.lower() """Human label for a disk: NVMe, SSD, eMMC, HDD or USB.
if "nvme" in name:
return 15
if "ssd" in name:
return 10
return 5
Uses lsblk's transport and rotational flags — the kernel name alone
def get_drive_type_label(device): can't tell an SSD from a spinning disk (both are sdX).
name = device.lower() """
if "nvme" in name: tran = (tran or "").lower()
if tran == "usb" or (removable and not name.startswith("mmcblk")):
return "USB"
if tran == "nvme" or name.startswith("nvme"):
return "NVMe" return "NVMe"
if "ssd" in name: if tran == "mmc" or name.startswith("mmcblk"):
return "eMMC"
if rota is False:
return "SSD" return "SSD"
return "HDD" return "HDD"
def parse_size_gb(size_str): def get_drive_type_score(type_label):
size_str = size_str.strip().upper().replace(",", ".") return _TYPE_SCORE.get(type_label, 5)
if not size_str:
return None
if size_str.endswith("T"):
return float(size_str[:-1]) * 1024
if size_str.endswith("G"):
return float(size_str[:-1])
if size_str.endswith("M"):
return float(size_str[:-1]) / 1024
return None
def get_size_score(size_gb): def get_size_score(size_gb):
@ -106,61 +133,98 @@ def get_size_score(size_gb):
return 10 return 10
def score_device(device, size_gb): def format_size(size_bytes):
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb) gb = size_bytes / 1024**3
if gb >= 1024:
return f"{gb / 1024:.1f} TB".replace(".0 ", " ")
if gb >= 10:
return f"{gb:.0f} GB"
return f"{gb:.1f} GB"
def parse_lsblk_output(output, boot_disk=None): def _flag(value):
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts. # util-linux ≥2.37 emits JSON booleans; older builds emit "0"/"1".
if isinstance(value, bool):
return value
return str(value).strip() in ("1", "true")
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
CD-ROM (rom) don't show up as install targets. If `boot_disk` is given, def parse_lsblk_json(output, boot_disk=None, uefi=True):
that disk is also dropped — it's the USB stick the live ISO booted from """Parse `lsblk -J -d -b -o NAME,SIZE,TYPE,RO,RM,TRAN,ROTA` into device dicts.
on bare metal, where it appears as TYPE=disk and would otherwise be a
valid-looking install target. Keeps only real, writable, non-empty TYPE=disk devices (drops the live
ISO's loop/rom, zram, eMMC boot areas, empty card-reader slots) and the
USB stick we booted from. Disks too small to install on stay in the list
with `usable=False` and a reason so the user sees why they're greyed out.
Sorted best-first; unusable disks last.
""" """
try:
entries = json.loads(output or "{}").get("blockdevices", [])
except json.JSONDecodeError:
return []
devices = [] devices = []
for line in output.strip().split("\n"): for e in entries:
if not line: name = e.get("name") or ""
if e.get("type") != "disk" or not name:
continue continue
parts = line.split() if _PSEUDO_DISK_RE.match(name) or (boot_disk and name == boot_disk):
if len(parts) < 3:
continue continue
name, size, dev_type = parts[0], parts[1], parts[2] try:
if dev_type != "disk": size_bytes = int(e.get("size") or 0)
continue except (TypeError, ValueError):
if boot_disk and name == boot_disk: size_bytes = 0
if size_bytes <= 0 or _flag(e.get("ro", False)):
continue continue
size_gb = size_bytes / 1024**3
type_label = classify_drive(
name,
tran=e.get("tran") or "",
rota=_flag(e.get("rota")) if e.get("rota") is not None else None,
removable=_flag(e.get("rm", False)),
)
device = f"/dev/{name}" device = f"/dev/{name}"
size_gb = parse_size_gb(size) usable = size_gb >= MIN_DISK_GB
status = _smart_status(device) status = _smart_status(device) if usable else "unknown"
score = get_drive_type_score(device) + _HEALTH_SCORE[status] + get_size_score(size_gb) warning = ""
if not uefi and size_gb > MBR_LIMIT_GB:
warning = (
"This computer starts in legacy (BIOS) mode, which can only use "
"the first 2 TB of this disk. Switch the firmware to UEFI to use all of it."
)
elif type_label == "USB":
warning = (
"This is a USB drive — it works, but an internal disk is faster and more reliable."
)
devices.append( devices.append(
{ {
"name": device, "name": device,
"size": size, "size": format_size(size_bytes),
"type_label": get_drive_type_label(device), "size_gb": size_gb,
"type_label": type_label,
"health_label": _HEALTH_LABEL[status], "health_label": _HEALTH_LABEL[status],
"score": score, "score": get_drive_type_score(type_label)
+ _HEALTH_SCORE[status]
+ get_size_score(size_gb),
"usable": usable,
"reason": "" if usable else f"Too small — Furtka needs at least {MIN_DISK_GB} GB",
"warning": warning,
} }
) )
devices.sort(key=lambda d: d["score"], reverse=True) devices.sort(key=lambda d: (d["usable"], d["score"]), reverse=True)
return devices return devices
def list_scored_devices(): def list_scored_devices():
"""Return [{name, size, score}, ...] for all physical disks, highest score first.""" """Return [{name, size, score, usable, ...}, ...] for all physical disks, best first."""
try: result = _run(["lsblk", "-J", "-d", "-b", "-o", "NAME,SIZE,TYPE,RO,RM,TRAN,ROTA"])
result = subprocess.run( if result is None or result.returncode != 0:
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"], print(f"Error listing devices: {result.stderr if result else 'lsblk unavailable'}")
capture_output=True,
text=True,
check=True,
)
except subprocess.CalledProcessError as e:
print(f"Error listing devices: {e}")
return [] return []
return parse_lsblk_output(result.stdout, boot_disk=_boot_disk_name()) return parse_lsblk_json(
result.stdout,
boot_disk=_boot_disk_name(),
uefi=Path("/sys/firmware/efi").exists(),
)
def main(): def main():
@ -168,11 +232,11 @@ def main():
if not devices: if not devices:
print("No storage devices found.") print("No storage devices found.")
return return
print(f"\n{'Device':<20} {'Size':<10} {'Score'}") print(f"\n{'Device':<20} {'Size':<10} {'Type':<6} {'Score'}")
print("-" * 40) print("-" * 46)
for d in devices: for d in devices:
print(f"{d['name']:<20} {d['size']:<10} {d['score']}") note = "" if d["usable"] else f" ({d['reason']})"
print(f"\nBest drive for boot: {devices[0]['name']} (score {devices[0]['score']})") print(f"{d['name']:<20} {d['size']:<10} {d['type_label']:<6} {d['score']}{note}")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -190,9 +190,17 @@ select:focus {
background: var(--bg-card); background: var(--bg-card);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: 8px; border-radius: 8px;
flex-wrap: wrap;
cursor: pointer; cursor: pointer;
transition: border-color 120ms, background 120ms; transition: border-color 120ms, background 120ms;
} }
.drive-disabled { cursor: not-allowed; opacity: 0.6; }
.drive-disabled:hover { border-color: var(--border); }
.drive .drive-note {
flex-basis: 100%;
font-size: 0.82rem;
color: var(--fg-muted);
}
.drive:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } .drive:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); }
.drive input[type="radio"] { .drive input[type="radio"] {
accent-color: var(--accent); accent-color: var(--accent);

View file

@ -18,10 +18,23 @@
</h1> </h1>
<p class="lede" id="install-lede"> <p class="lede" id="install-lede">
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk. {% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help. {% elif progress.status == "error" %}{{ progress.phase }}.
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %} {% else %}This takes 5–20 minutes, depending on your internet speed. Don't power off the machine — it's fine to close this page and come back.{% endif %}
</p> </p>
{% if progress.status == "error" %}
<p>Your settings are kept, so you can simply try again. If it fails the same way twice,
open <em>Show details</em> below — the last lines of the log say what went wrong.</p>
<form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true;">
<div class="actions">
<button type="submit" class="btn btn-primary">Try again</button>
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">Pick a different disk</a>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">Change settings</a>
</div>
</form>
{% endif %}
{% if progress.status == "done" %} {% if progress.status == "done" %}
{# The USB-stick step has to be read *before* the click: the machine reboots {# The USB-stick step has to be read *before* the click: the machine reboots
3 s after it, and most BIOSes boot the stick before the disk — so the 3 s after it, and most BIOSes boot the stick before the disk — so the
@ -43,7 +56,7 @@
<div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}"> <div class="progress" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="{{ progress.percent }}">
<div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div> <div id="progress-bar" class="progress-bar{% if progress.status == 'error' %} progress-bar-error{% endif %}{% if progress.status == 'done' %} progress-bar-done{% endif %}" style="width: {{ progress.percent }}%;"></div>
</div> </div>
<p class="progress-phase"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p> <p class="progress-phase" aria-live="polite"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
<details class="log-details"> <details class="log-details">
<summary>Show details</summary> <summary>Show details</summary>
@ -59,17 +72,7 @@
var phaseEl = document.getElementById('progress-phase'); var phaseEl = document.getElementById('progress-phase');
var percentEl = document.getElementById('progress-percent'); var percentEl = document.getElementById('progress-percent');
var logEl = document.getElementById('install-log'); var logEl = document.getElementById('install-log');
var headingEl = document.getElementById('install-heading'); var failures = 0;
var ledeEl = document.getElementById('install-lede');
var HEADINGS = {
done: 'Furtka is ready',
error: 'Installation hit a snag',
};
var LEDES = {
done: 'Installation finished. Remove the USB / eject the installer image, then reboot.',
error: 'Something went wrong. Open the details below and share them so we can help.',
};
function tick() { function tick() {
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' }) fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
@ -77,6 +80,7 @@
.then(function (data) { .then(function (data) {
var p = data.progress; var p = data.progress;
bar.style.width = p.percent + '%'; bar.style.width = p.percent + '%';
bar.parentNode.setAttribute('aria-valuenow', p.percent);
phaseEl.textContent = p.phase; phaseEl.textContent = p.phase;
percentEl.textContent = p.percent; percentEl.textContent = p.percent;
if (logEl.textContent !== data.log) { if (logEl.textContent !== data.log) {
@ -84,21 +88,23 @@
logEl.textContent = data.log || '(waiting for install to start)'; logEl.textContent = data.log || '(waiting for install to start)';
if (atBottom) logEl.scrollTop = logEl.scrollHeight; if (atBottom) logEl.scrollTop = logEl.scrollHeight;
} }
if (p.status === 'done') { if (p.status === 'done' || p.status === 'error') {
// Reload so the server-rendered Done state (with the // Reload so the server-rendered Done / error state (with
// Reboot button) replaces the running-state markup. // its Reboot or Try-again buttons) replaces the
// running-state markup.
window.location.reload(); window.location.reload();
return; return;
} }
if (p.status === 'error') { failures = 0;
bar.classList.add('progress-bar-error');
headingEl.textContent = HEADINGS.error;
ledeEl.textContent = LEDES.error;
return;
}
setTimeout(tick, 3000); setTimeout(tick, 3000);
}) })
.catch(function () { setTimeout(tick, 3000); }); .catch(function () {
failures += 1;
if (failures >= 2) {
phaseEl.textContent = 'Lost connection to the installer — retrying…';
}
setTimeout(tick, 3000);
});
} }
setTimeout(tick, 3000); setTimeout(tick, 3000);
})(); })();

View file

@ -5,27 +5,27 @@
{% block content %} {% block content %}
<h1>Confirm install</h1> <h1>Confirm install</h1>
<p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p> <p class="lede">Check the settings below. Continuing will <strong>erase <code>{{ settings.boot_drive }}</code></strong> and install Furtka on it.</p>
<div class="alert alert-warn"> <div class="alert alert-warn" role="alert">
This is destructive. All existing data on the selected boot drive will be lost. Everything currently on this disk will be deleted and can't be recovered.
</div> </div>
<div class="summary"> <div class="summary">
<table> <table>
{% for k, v in settings.items() %} <tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
<tr> <tr><td>Username</td><td>{{ settings.username }}</td></tr>
<td>{{ k.replace('_', ' ') }}</td> <tr><td>Password</td><td>{{ settings.password }}</td></tr>
<td>{{ v }}</td> <tr><td>Language</td><td>{{ language_label }}</td></tr>
</tr> <tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
{% endfor %}
</table> </table>
</div> </div>
<form method="post" action="{{ url_for('install_run') }}"> <form method="post" action="{{ url_for('install_run') }}"
onsubmit="this.querySelector('button').disabled = true; this.querySelector('button').textContent = 'Starting…';">
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button> <button type="submit" class="btn btn-danger">Erase disk and install</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a> <a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -10,5 +10,7 @@
<p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p> <p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p>
<p>When the machine comes back up (~1 minute), open Furtka in your browser:</p> <p>When the machine comes back up (~1 minute), open Furtka in your browser:</p>
<p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p> <p><a href="http://{{ hostname }}.local" class="btn btn-primary">http://{{ hostname }}.local</a></p>
<p class="muted">If that doesn't resolve, your network may not support mDNS — use the IP address shown on the machine's console instead.</p> <p class="muted">If that address doesn't open (some Android phones and older Windows PCs can't look up <code>.local</code> names),
use the IP address instead: it's shown on the machine's screen, or look for a device called
<strong>{{ hostname }}</strong> in your router's list of connected devices. The address may differ from the one the installer used.</p>
{% endblock %} {% endblock %}

View file

@ -4,11 +4,11 @@
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %} {% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
{% block content %} {% block content %}
<h1>Account &amp; hostname</h1> <h1>Name &amp; account</h1>
<p class="lede">Set the server name and your administrator account.</p> <p class="lede">Set the server name and your administrator account.</p>
{% if errors %} {% if errors %}
<div class="alert alert-error"> <div class="alert alert-error" role="alert">
<strong>Please fix the following:</strong> <strong>Please fix the following:</strong>
<ul> <ul>
{% for e in errors %}<li>{{ e }}</li>{% endfor %} {% for e in errors %}<li>{{ e }}</li>{% endfor %}
@ -18,19 +18,19 @@
<form method="post" action="{{ url_for('install_step_1') }}"> <form method="post" action="{{ url_for('install_step_1') }}">
<div class="field"> <div class="field">
<label for="hostname">Hostname</label> <label for="hostname">Server name</label>
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" /> <input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span> <span class="hint" id="hostname-hint">You'll open your server at <strong>http://<span id="hostname-preview">{{ values.hostname or "furtka" }}</span>.local</strong>. Letters, numbers and dashes only.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="username">Admin username</label> <label for="username">Admin username</label>
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" /> <input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" />
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span> <span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="new-password" /> <input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
<span class="hint">At least 8 characters.</span> <span class="hint" id="password-hint">At least 8 characters. This one password is used for the web login and for the server itself (SSH / console), so pick a good one.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="password2">Confirm password</label> <label for="password2">Confirm password</label>
@ -38,15 +38,25 @@
</div> </div>
<div class="field"> <div class="field">
<label for="language">Language</label> <label for="language">Language</label>
<select id="language" name="language"> <select id="language" name="language" aria-describedby="language-hint">
{% for code, lang in languages.items() %} {% for code, lang in languages.items() %}
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option> <option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
{% endfor %} {% endfor %}
</select> </select>
<span class="hint" id="language-hint">Language and keyboard layout of the installed server. The Furtka pages themselves are in English for now.</span>
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary">Next — pick boot drive</button> <button type="submit" class="btn btn-primary">Next — pick a disk</button>
</div> </div>
</form> </form>
<script>
(function () {
var input = document.getElementById('hostname');
var preview = document.getElementById('hostname-preview');
input.addEventListener('input', function () {
preview.textContent = input.value.trim().toLowerCase() || 'furtka';
});
})();
</script>
{% endblock %} {% endblock %}

View file

@ -1,39 +1,53 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %} {% block title %}Step 2 — Disk · Furtka Installer{% endblock %}
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %} {% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
{% block content %} {% block content %}
<h1>Boot drive</h1> <h1>Pick a disk</h1>
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p> <p class="lede">Choose the disk Furtka will be installed on. <strong>Everything on it will be erased.</strong> We pre-select the one that looks fastest and healthiest.</p>
{% if not drives %} {% set usable = drives | selectattr("usable") | list %}
<div class="alert alert-error"> {% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
No physical disks detected. The live ISO can't see any install targets — check your BIOS / SATA controller.
{% if error %}
<div class="alert alert-error" role="alert">{{ error }}</div>
{% endif %}
{% if not usable %}
<div class="alert alert-error" role="alert">
{% if drives %}
None of the disks in this computer is big enough for Furtka (it needs at least 16 GB).
{% else %}
No disk found to install onto. Check that the disk is connected, and in the BIOS/UEFI settings
that the SATA mode is "AHCI" rather than "RAID" or "Intel RST" — Linux can't see disks in RAID mode.
{% endif %}
</div> </div>
{% endif %} {% endif %}
<form method="post" action="{{ url_for('install_step_2') }}"> <form method="post" action="{{ url_for('install_step_2') }}">
<div class="drive-list"> <div class="drive-list">
{% for d in drives %} {% for d in drives %}
<label class="drive"> <label class="drive{% if not d.usable %} drive-disabled{% endif %}">
<input type="radio" name="boot_drive" value="{{ d.name }}" <input type="radio" name="boot_drive" value="{{ d.name }}"
{% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} /> {% if not d.usable %}disabled{% elif d.name == pick %}checked{% endif %} />
<span class="name">{{ d.name }}</span> <span class="name">{{ d.name }}</span>
{% if not selected and loop.first %} {% if not selected and d.usable and loop.first %}
<span class="badge-recommended">Recommended</span> <span class="badge-recommended">Recommended</span>
{% endif %} {% endif %}
<span class="meta"> <span class="meta">
<span class="chip">{{ d.size }}</span> <span class="chip">{{ d.size }}</span>
<span class="chip">{{ d.type_label }}</span> <span class="chip">{{ d.type_label }}</span>
<span class="chip">{{ d.health_label }}</span> {% if d.usable %}<span class="chip">{{ d.health_label }}</span>{% endif %}
</span> </span>
{% if d.reason %}<span class="drive-note">{{ d.reason }}</span>{% endif %}
{% if d.warning %}<span class="drive-note">{{ d.warning }}</span>{% endif %}
</label> </label>
{% endfor %} {% endfor %}
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit" class="btn btn-primary" {% if not drives %}disabled{% endif %}>Next — review</button> <button type="submit" class="btn btn-primary" {% if not usable %}disabled{% endif %}>Next — review</button>
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a> <a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a>
</div> </div>
</form> </form>

View file

@ -1,4 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# ruff: noqa: E501 — inline SVG markup reads better unwrapped
"""Generate the logo-poll showcase assets for /logo/ from one geometry source. """Generate the logo-poll showcase assets for /logo/ from one geometry source.
python3 website/tools/logo-assets.py python3 website/tools/logo-assets.py
@ -10,6 +11,7 @@ the output — the site build itself needs nothing beyond Hugo.
Mark M: lowercase f knocked out of a gate, 64-unit grid (even-odd fill). Mark M: lowercase f knocked out of a gate, 64-unit grid (even-odd fill).
Mark R: window, wheel and F, 104x114 grid, stroke 8 (from Robert's sketch). Mark R: window, wheel and F, 104x114 grid, stroke 8 (from Robert's sketch).
""" """
import base64 import base64
import io import io
import json import json
@ -119,7 +121,9 @@ def raster(svgtxt, w, h):
def mark_png(k, tier, height, color=None): def mark_png(k, tier, height, color=None):
_, _, w, h = MARKS[k]["vb"] _, _, w, h = MARKS[k]["vb"]
return raster(svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height) return raster(
svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height
)
# ---------------------------------------------------------------- wordmark # ---------------------------------------------------------------- wordmark
@ -141,7 +145,9 @@ def wordmark(text, size, x0=0.0, baseline=0.0, tracking=-0.028):
g.draw(pen) g.draw(pen)
d = pen.getCommands() d = pen.getCommands()
if d: if d:
parts.append(f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>') parts.append(
f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>'
)
x += g.width * s + tracking * size x += g.width * s + tracking * size
return "".join(parts), x - x0 - tracking * size return "".join(parts), x - x0 - tracking * size
@ -241,21 +247,24 @@ def vote_card(path):
m = mark_png(k, "flat", 330) m = mark_png(k, "flat", 330)
im.alpha_composite(m, (cx - m.width // 2, 110 - (m.height - 330) // 2)) im.alpha_composite(m, (cx - m.width // 2, 110 - (m.height - 330) // 2))
size = 46 size = 46
d = ImageDraw.Draw(im)
for text, cx, col in (("M", W // 4, "#6b6b6f"), ("R", 3 * W // 4, "#8a8a90")): for text, cx, col in (("M", W // 4, "#6b6b6f"), ("R", 3 * W // 4, "#8a8a90")):
paths, tw = wordmark(text, size) paths, tw = wordmark(text, size)
# letters via the same outlines: rasterise a tiny SVG for crisp text # letters via the same outlines: rasterise a tiny SVG for crisp text
svgtxt = (f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">' svgtxt = (
f'<g fill="{col}">{paths}</g></svg>') f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">'
f'<g fill="{col}">{paths}</g></svg>'
)
t = raster(svgtxt, round(tw), round(size * 1.3)) t = raster(svgtxt, round(tw), round(size * 1.3))
im.alpha_composite(t, (cx - t.width // 2, 500)) im.alpha_composite(t, (cx - t.width // 2, 500))
# url line, centred on the seam # url line, centred on the seam
paths, tw = wordmark("furtka.org/logo", 40) paths, tw = wordmark("furtka.org/logo", 40)
svgtxt = (f'<svg {XMLNS} viewBox="0 -40 {tw:.0f} 52"><g fill="#c03a28">{paths}</g></svg>') svgtxt = f'<svg {XMLNS} viewBox="0 -40 {tw:.0f} 52"><g fill="#c03a28">{paths}</g></svg>'
t = raster(svgtxt, round(tw), 52) t = raster(svgtxt, round(tw), 52)
pad = 18 pad = 18
box = Image.new("RGBA", (t.width + 2 * pad, t.height + 2 * pad), "#f7f6f3") box = Image.new("RGBA", (t.width + 2 * pad, t.height + 2 * pad), "#f7f6f3")
ImageDraw.Draw(box).rounded_rectangle([0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc") ImageDraw.Draw(box).rounded_rectangle(
[0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc"
)
box.alpha_composite(t, (pad, pad)) box.alpha_composite(t, (pad, pad))
im.alpha_composite(box, (W // 2 - box.width // 2, 40)) im.alpha_composite(box, (W // 2 - box.width // 2, 40))
im.convert("RGB").save(path, optimize=True) im.convert("RGB").save(path, optimize=True)