Compare commits

..

No commits in common. "b7b66fe1364061f45fd784c95435310bd6db31b1" and "722dac484301b5841032a0c7cb08c83954f5c9b6" have entirely different histories.

29 changed files with 359 additions and 1445 deletions

View file

@ -7,50 +7,8 @@ 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
@ -61,14 +19,6 @@ 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,15 +68,6 @@
</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,15 +122,6 @@
</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();
@ -171,7 +162,6 @@
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;
@ -355,13 +345,12 @@
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}`;
const failed = s.stage === 'rolled_back' || s.stage === 'error'; setStatus(label, s.stage === 'rolled_back');
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 (failed) { } else if (s.stage === 'rolled_back') {
clearInterval(pollHandle); clearInterval(pollHandle);
clearTimeout(fallbackReloadHandle); clearTimeout(fallbackReloadHandle);
if (s.reason) { if (s.reason) {

View file

@ -15,7 +15,6 @@ 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
@ -34,9 +33,7 @@ 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 (OSError, http.client.HTTPException, json.JSONDecodeError) as e: except (urllib.error.URLError, 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
@ -46,8 +43,7 @@ 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 (OSError, http.client.HTTPException) as e: except urllib.error.URLError 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,14 +17,12 @@ 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, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
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 APP_NAME_RE, ManifestError, load_manifest from furtka.manifest import 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
@ -122,15 +120,6 @@ _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);
@ -776,7 +765,7 @@ def _do_get_settings(name):
} }
_INSTALL_TERMINAL_STAGES = install_runner.TERMINAL_STAGES _INSTALL_TERMINAL_STAGES = frozenset({"done", "error"})
def _do_install_plan(name): def _do_install_plan(name):
@ -830,9 +819,8 @@ 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", "") current_stage = current_state.get("stage", "") if isinstance(current_state, dict) else ""
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": (
@ -928,7 +916,6 @@ 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()
@ -1251,33 +1238,7 @@ 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)
@ -1386,14 +1347,6 @@ 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
@ -1440,39 +1393,13 @@ 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")
try: return self._html(200, _render_login_html(auth.setup_needed()))
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()
@ -1520,26 +1447,14 @@ 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 not _valid_app_name(name): if "/" in name or not 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 _route_post(self): def do_POST(self): # noqa: N802
# 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")) 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 {}
@ -1558,15 +1473,10 @@ 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 not _valid_app_name(name): if "/" in name or not 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:
@ -1577,7 +1487,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 not _valid_app_name(name): if "/" in name or not 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)
@ -1609,8 +1519,6 @@ 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)
@ -1633,8 +1541,7 @@ 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 = ThreadingHTTPServer((host, port), _Handler) server = HTTPServer((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,7 +23,6 @@ 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
@ -47,33 +46,27 @@ 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. """Return the users dict, or {} if the file is missing or empty.
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 FileNotFoundError: except OSError:
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 as e: except json.JSONDecodeError:
raise UsersFileError(f"{path} is corrupt: {e}") from e return {}
if not isinstance(data, dict): if not isinstance(data, dict):
raise UsersFileError(f"{path} is corrupt: not a JSON object") return {}
return data return data
@ -87,12 +80,7 @@ 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")
with open(tmp, "w") as fh: tmp.write_text(json.dumps(users, indent=2) + "\n")
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,9 +216,6 @@ 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,53 +91,9 @@ def write_state(stage: str, **extra) -> None:
def read_state() -> dict: def read_state() -> dict:
try: try:
data = json.loads(state_path().read_text()) return json.loads(state_path().read_text())
except (OSError, UnicodeDecodeError, json.JSONDecodeError): except (FileNotFoundError, 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 APP_NAME_RE, Manifest, ManifestError, load_manifest from furtka.manifest import 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,26 +35,19 @@ 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 []
return _placeholder_values(_read_env(env_path)) bad: list[str] = []
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:
@ -66,13 +59,16 @@ def _is_denied_system_path(resolved: str) -> bool:
return False return False
def _path_setting_errors(m: Manifest, values: dict[str, str]) -> list[str]: def _path_setting_errors(m: Manifest, env_path: Path) -> 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":
@ -101,35 +97,10 @@ def _path_setting_errors(m: Manifest, values: dict[str, str]) -> 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}"'
@ -196,7 +167,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 = app_dir(m.name) target = apps_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():
@ -245,7 +216,6 @@ 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
@ -265,7 +235,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, read_env_values(env)) path_errors = _path_setting_errors(m, env)
if path_errors: if path_errors:
raise InstallError(f"{m.name}: {'; '.join(path_errors)}") raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
@ -286,11 +256,10 @@ 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 len(value) >= 2 and value.startswith('"') and value.endswith('"'): if (value.startswith('"') and value.endswith('"')) or (
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
@ -315,7 +284,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 = app_dir(name) target = apps_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")
@ -347,8 +316,13 @@ 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
@ -379,7 +353,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 = app_dir(name) target = apps_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,10 +175,8 @@ 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 APP_NAME_RE.fullmatch(name): if not isinstance(name, str) or not name:
raise ManifestError( raise ManifestError(f"{path}: name must be a non-empty string")
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,7 +29,6 @@ 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
@ -266,10 +265,9 @@ def write_state(stage: str, **extra) -> None:
def read_state() -> dict: def read_state() -> dict:
try: try:
data = json.loads(state_path().read_text()) return json.loads(state_path().read_text())
except (OSError, UnicodeDecodeError, json.JSONDecodeError): except (FileNotFoundError, json.JSONDecodeError):
return {} return {}
return data if isinstance(data, dict) else {}
def acquire_lock(): def acquire_lock():
@ -320,29 +318,13 @@ 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 (OSError, http.client.HTTPException): except urllib.error.URLError:
# Connection refused / reset / timeout (URLError is an OSError; # Connection refused / DNS / timeout → not up yet, retry.
# 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:
@ -387,11 +369,15 @@ 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 = os.readlink(current) if current.is_symlink() else None previous = None
old_caddyfile = _CADDYFILE_LIVE.read_text() if _CADDYFILE_LIVE.is_file() else None if current.is_symlink():
previous = os.readlink(current)
current.unlink()
try: try:
_swap_symlink(current, target) current.symlink_to(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)
@ -407,41 +393,27 @@ 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, OSError) as e: except UpdateError as e:
# OSError: e.g. disk full or EACCES writing the Caddyfile — the new _rollback(previous, version, f"service restart failed: {e}")
# version is already live, so this must roll back too. raise
_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", old_caddyfile) _rollback(previous, version, "health check failed after restart")
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( def _rollback(previous_target: str | None, failed_version: str, reason: str) -> None:
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:
_swap_symlink(current, previous_target) if current.is_symlink():
# The new release may have shipped Caddy routes the old code current.unlink()
# doesn't serve; put the previous Caddyfile back before reloading. current.symlink_to(previous_target)
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",
@ -458,7 +430,6 @@ 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():
try:
check = check_update() check = check_update()
if not check.update_available: if not check.update_available:
write_state("done", version=check.current, note="already up to date") write_state("done", version=check.current, note="already up to date")
@ -466,17 +437,6 @@ def run_update() -> UpdateCheck:
tarball, version = prepare_update(check) tarball, version = prepare_update(check)
apply_update(tarball, version) apply_update(tarball, version)
return check return check
except Exception as e:
# Without a terminal stage the settings page keeps showing
# "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:
@ -496,7 +456,8 @@ 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
_swap_symlink(current, target) current.unlink()
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,8 +30,6 @@ 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.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral server = api.HTTPServer(("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.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) server = api.HTTPServer(("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.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) server = api.HTTPServer(("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.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) server = api.HTTPServer(("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.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) server = api.HTTPServer(("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.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) server = api.HTTPServer(("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,103 +1424,3 @@ 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,167 +226,3 @@ 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,15 +40,14 @@ def test_load_users_returns_empty_when_missing(tmp_users_file):
assert auth.load_users() == {} assert auth.load_users() == {}
@pytest.mark.parametrize("content", ["{not json", "[]", "", "\x00\x00"]) def test_load_users_returns_empty_on_junk(tmp_users_file):
def test_load_users_fails_closed_on_corrupt_file(tmp_users_file, content): tmp_users_file.write_text("{not json")
"""A damaged users.json must not read as "no admin yet" — that would assert auth.load_users() == {}
turn /login into an open create-admin form for anyone on the LAN."""
tmp_users_file.write_text(content)
with pytest.raises(auth.UsersFileError): def test_load_users_returns_empty_on_non_dict(tmp_users_file):
auth.load_users() tmp_users_file.write_text("[]")
with pytest.raises(auth.UsersFileError): assert auth.load_users() == {}
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,30 +349,3 @@ 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,63 +1,47 @@
import json
import drives
import pytest
from drives import ( from drives import (
MIN_DISK_GB, get_drive_type_label,
_boot_device_from_cmdline,
classify_drive,
format_size,
get_drive_type_score, get_drive_type_score,
get_size_score, get_size_score,
parse_lsblk_json, parse_lsblk_output,
parse_size_gb,
score_device,
) )
GB = 1024**3
def test_parse_size_gb_terabytes():
assert parse_size_gb("1T") == 1024.0
def _lsblk(*entries): def test_parse_size_gb_gigabytes():
"""Build lsblk -J output. Each entry: (name, size_gb, type, extra-dict).""" assert parse_size_gb("500G") == 500.0
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})
@pytest.fixture(autouse=True) def test_parse_size_gb_megabytes():
def _smart_passed(monkeypatch): assert parse_size_gb("2048M") == 2.0
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
def _names(devices): def test_parse_size_gb_european_comma_decimal():
return [d["name"] for d in devices] assert parse_size_gb("1,5T") == 1.5 * 1024
def test_classify_uses_transport_and_rotation(): def test_parse_size_gb_empty_returns_none():
assert classify_drive("nvme0n1", tran="nvme", rota=False) == "NVMe" assert parse_size_gb("") is None
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_type_scores_rank_internal_over_usb(): def test_parse_size_gb_unknown_unit_returns_none():
assert get_drive_type_score("NVMe") > get_drive_type_score("SSD") assert parse_size_gb("500K") is None
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():
@ -67,145 +51,67 @@ def test_size_score_bands():
assert get_size_score(1024) == 10 assert get_size_score(1024) == 10
def test_format_size(): def test_score_device_sums_type_and_size(monkeypatch):
assert format_size(500 * GB) == "500 GB" import drives
assert format_size(1024 * GB) == "1 TB"
assert format_size(int(1.5 * 1024 * GB)) == "1.5 TB" monkeypatch.setattr(drives, "get_drive_health", lambda _: 10)
assert format_size(int(7.5 * GB)) == "7.5 GB" assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10
assert score_device("/dev/sda", 64) == 5 + 10 + 5
def test_drops_loop_rom_zram_and_emmc_boot_areas(): def test_parse_lsblk_drops_loop_and_rom(monkeypatch):
out = _lsblk( import drives
("loop0", 2.5, "loop"),
("sr0", 1, "rom"), monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
("zram0", 4, "disk"), output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n"
("mmcblk0boot0", 0.004, "disk"), devices = parse_lsblk_output(output)
("mmcblk0boot1", 0.004, "disk"), names = [d["name"] for d in devices]
("mmcblk0rpmb", 0.004, "disk"), assert names == ["/dev/nvme0n1", "/dev/sda"]
("mmcblk0", 64, "disk", {"tran": "", "rota": False}),
)
assert _names(parse_lsblk_json(out)) == ["/dev/mmcblk0"]
def test_drops_empty_card_reader_slots_and_read_only_disks(): def test_parse_lsblk_attaches_human_labels(monkeypatch):
out = _lsblk( import drives
("sdc", 0, "disk", {"rm": True, "tran": "usb"}),
("sdd", 500, "disk", {"ro": True}), monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
("sda", 500, "disk"), output = "nvme0n1 1T disk\n"
) [dev] = parse_lsblk_output(output)
assert _names(parse_lsblk_json(out)) == ["/dev/sda"] assert dev["type_label"] == "NVMe"
assert dev["health_label"] == "Healthy"
def test_ssd_beats_bigger_hdd(): def test_parse_lsblk_surfaces_smart_warning(monkeypatch):
# Regression: sdX SSDs used to be labelled HDD, so the 2 TB spinning disk import drives
# 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_json(_lsblk(("sda", 500, "disk"))) [dev] = parse_lsblk_output("sda 500G disk\n")
assert dev["health_label"] == "SMART warning" assert dev["health_label"] == "SMART warning"
def test_accepts_old_util_linux_string_flags(): def test_drive_type_label_nvme_ssd_hdd():
out = json.dumps( assert get_drive_type_label("/dev/nvme0n1") == "NVMe"
{ assert get_drive_type_label("/dev/ssd0") == "SSD"
"blockdevices": [ assert get_drive_type_label("/dev/sda") == "HDD"
{
"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_handles_empty_and_garbage_output(): def test_parse_lsblk_handles_empty_output():
assert parse_lsblk_json("") == [] assert parse_lsblk_output("") == []
assert parse_lsblk_json("not json") == []
def test_drops_boot_usb(): def test_parse_lsblk_drops_boot_usb(monkeypatch):
out = _lsblk( import drives
("sda", 500, "disk"),
("sdb", 16, "disk", {"tran": "usb"}), monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
("nvme0n1", 1024, "disk", {"tran": "nvme", "rota": False}), output = "sda 500G disk\nsdb 16G disk\nnvme0n1 1T disk\n"
) devices = parse_lsblk_output(output, boot_disk="sdb")
assert _names(parse_lsblk_json(out, boot_disk="sdb")) == ["/dev/nvme0n1", "/dev/sda"] names = [d["name"] for d in devices]
assert "/dev/sdb" not in names
assert names == ["/dev/nvme0n1", "/dev/sda"]
def test_boot_device_from_cmdline(monkeypatch): def test_parse_lsblk_no_boot_disk_keeps_all(monkeypatch):
calls = [] import drives
class R: monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
returncode = 0 output = "sda 500G disk\nsdb 16G disk\n"
stdout = "/dev/sdb1\n" names = [d["name"] for d in parse_lsblk_output(output, boot_disk=None)]
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,35 +478,3 @@ 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,58 +462,3 @@ 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,79 +463,3 @@ 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,8 +8,6 @@ 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
@ -78,35 +76,6 @@ 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
@ -139,13 +108,7 @@ PROGRESS_ERROR_HINTS = [
] ]
def parse_install_progress(log, exit_code=None): def parse_install_progress(log):
"""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:
@ -155,7 +118,7 @@ def parse_install_progress(log, exit_code=None):
if percent >= 100: if percent >= 100:
status = "done" status = "done"
elif any(m in log for m in PROGRESS_ERROR_MARKERS) or exit_code not in (None, 0): elif any(m in log for m in PROGRESS_ERROR_MARKERS):
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:
@ -164,39 +127,24 @@ def parse_install_progress(log, exit_code=None):
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().lower(), "hostname": form.get("hostname", "").strip(),
"username": form.get("username", "").strip().lower(), "username": form.get("username", "").strip(),
"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( errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).")
"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( errors.append("Username must start with a letter or underscore, lowercase only.")
"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:
@ -490,16 +438,6 @@ 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(),
@ -632,16 +570,6 @@ 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"))
@ -666,89 +594,32 @@ 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()
# Only accept a disk we actually offered — never a free-form path. if boot_drive:
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=drives, drives=list_scored_devices(),
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 ""}
drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None return render_template("install/overview.html", settings=masked)
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"))
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"")
try:
config_path, creds_path = write_install_files(settings, STATE_DIR) config_path, creds_path = write_install_files(settings, STATE_DIR)
except Exception: INSTALL_LOG.write_bytes(b"")
# 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} "
@ -760,33 +631,33 @@ def _start_install():
# 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")
_install_proc = spawn_archinstall(config_path, creds_path, INSTALL_LOG) 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, code = _read_install_log() log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
return render_template( return render_template(
"install/log.html", "install/log.html",
log=log, log=log,
progress=parse_install_progress(log, code), progress=parse_install_progress(log),
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, code = _read_install_log() log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
return jsonify(log=log, progress=parse_install_progress(log, code)) return jsonify(log=log, progress=parse_install_progress(log))
@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, code = _read_install_log() log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
if parse_install_progress(log, code)["status"] != "done": if parse_install_progress(log)["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,90 +1,56 @@
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 bare metal booted from USB this is the stick we booted from — we want On a normal box `/run/archiso/bootmnt` does not exist and we return None,
to filter it out so the user can't accidentally pick it as the install leaving the device list untouched. On bare metal booted from USB this is
target. Primary source is the mount behind /run/archiso/bootmnt; when the stick we booted from — we want to filter it out so the user can't
that's gone (copytoram unmounts it) fall back to the UUID/label archiso accidentally pick it as the install target.
put on the kernel command line.
""" """
partition = None
result = _run(["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"])
if result is not None and result.returncode == 0:
partition = result.stdout.strip() or None
if partition is None:
try: try:
partition = _boot_device_from_cmdline(Path("/proc/cmdline").read_text()) result = subprocess.run(
except OSError: ["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"],
partition = None capture_output=True,
if partition is None: text=True,
)
except FileNotFoundError:
return None return None
# A stick written with dd has the ISO filesystem on the whole disk, where if result.returncode != 0:
# PKNAME is empty — the device itself is then the disk. return None
return _parent_disk(partition) or Path(partition).name partition = result.stdout.strip()
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):
result = _run(["smartctl", "-H", device]) try:
if result is None: result = subprocess.run(
return "unknown" ["smartctl", "-H", device],
if "PASSED" in result.stdout: capture_output=True,
)
output = result.stdout.decode()
if "PASSED" in output:
return "passed" return "passed"
if "FAILED" in result.stdout: elif "FAILED" in output:
return "failed" return "failed"
return "unknown" return "unknown"
except Exception as e:
print(f"Error checking SMART status for {device}: {e}")
return "unknown"
_HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5} _HEALTH_SCORE = {"passed": 10, "failed": 0, "unknown": 5}
@ -94,33 +60,40 @@ _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 classify_drive(name, tran="", rota=None, removable=False): def get_drive_type_score(device):
"""Human label for a disk: NVMe, SSD, eMMC, HDD or USB. name = device.lower()
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
can't tell an SSD from a spinning disk (both are sdX). def get_drive_type_label(device):
""" name = device.lower()
tran = (tran or "").lower() if "nvme" in name:
if tran == "usb" or (removable and not name.startswith("mmcblk")):
return "USB"
if tran == "nvme" or name.startswith("nvme"):
return "NVMe" return "NVMe"
if tran == "mmc" or name.startswith("mmcblk"): if "ssd" in name:
return "eMMC"
if rota is False:
return "SSD" return "SSD"
return "HDD" return "HDD"
def get_drive_type_score(type_label): def parse_size_gb(size_str):
return _TYPE_SCORE.get(type_label, 5) size_str = size_str.strip().upper().replace(",", ".")
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):
@ -133,98 +106,61 @@ def get_size_score(size_gb):
return 10 return 10
def format_size(size_bytes): def score_device(device, size_gb):
gb = size_bytes / 1024**3 return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
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 _flag(value): def parse_lsblk_output(output, boot_disk=None):
# util-linux ≥2.37 emits JSON booleans; older builds emit "0"/"1". """Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts.
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
def parse_lsblk_json(output, boot_disk=None, uefi=True): CD-ROM (rom) don't show up as install targets. If `boot_disk` is given,
"""Parse `lsblk -J -d -b -o NAME,SIZE,TYPE,RO,RM,TRAN,ROTA` into device dicts. that disk is also dropped — it's the USB stick the live ISO booted from
on bare metal, where it appears as TYPE=disk and would otherwise be a
Keeps only real, writable, non-empty TYPE=disk devices (drops the live valid-looking install target.
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 e in entries: for line in output.strip().split("\n"):
name = e.get("name") or "" if not line:
if e.get("type") != "disk" or not name:
continue continue
if _PSEUDO_DISK_RE.match(name) or (boot_disk and name == boot_disk): parts = line.split()
if len(parts) < 3:
continue continue
try: name, size, dev_type = parts[0], parts[1], parts[2]
size_bytes = int(e.get("size") or 0) if dev_type != "disk":
except (TypeError, ValueError): continue
size_bytes = 0 if boot_disk and name == boot_disk:
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}"
usable = size_gb >= MIN_DISK_GB size_gb = parse_size_gb(size)
status = _smart_status(device) if usable else "unknown" status = _smart_status(device)
warning = "" score = get_drive_type_score(device) + _HEALTH_SCORE[status] + get_size_score(size_gb)
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": format_size(size_bytes), "size": size,
"size_gb": size_gb, "type_label": get_drive_type_label(device),
"type_label": type_label,
"health_label": _HEALTH_LABEL[status], "health_label": _HEALTH_LABEL[status],
"score": get_drive_type_score(type_label) "score": score,
+ _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["usable"], d["score"]), reverse=True) devices.sort(key=lambda d: d["score"], reverse=True)
return devices return devices
def list_scored_devices(): def list_scored_devices():
"""Return [{name, size, score, usable, ...}, ...] for all physical disks, best first.""" """Return [{name, size, score}, ...] for all physical disks, highest score first."""
result = _run(["lsblk", "-J", "-d", "-b", "-o", "NAME,SIZE,TYPE,RO,RM,TRAN,ROTA"]) try:
if result is None or result.returncode != 0: result = subprocess.run(
print(f"Error listing devices: {result.stderr if result else 'lsblk unavailable'}") ["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"],
return [] capture_output=True,
return parse_lsblk_json( text=True,
result.stdout, check=True,
boot_disk=_boot_disk_name(),
uefi=Path("/sys/firmware/efi").exists(),
) )
except subprocess.CalledProcessError as e:
print(f"Error listing devices: {e}")
return []
return parse_lsblk_output(result.stdout, boot_disk=_boot_disk_name())
def main(): def main():
@ -232,11 +168,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} {'Type':<6} {'Score'}") print(f"\n{'Device':<20} {'Size':<10} {'Score'}")
print("-" * 46) print("-" * 40)
for d in devices: for d in devices:
note = "" if d["usable"] else f" ({d['reason']})" print(f"{d['name']:<20} {d['size']:<10} {d['score']}")
print(f"{d['name']:<20} {d['size']:<10} {d['type_label']:<6} {d['score']}{note}") print(f"\nBest drive for boot: {devices[0]['name']} (score {devices[0]['score']})")
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -190,17 +190,9 @@ 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,23 +18,10 @@
</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" %}{{ progress.phase }}. {% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
{% 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 %} {% else %}This takes a few minutes. Don't close this page or power off the machine.{% 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
@ -56,7 +43,7 @@ open <em>Show details</em> below — the last lines of the log say what went wro
<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" aria-live="polite"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p> <p class="progress-phase"><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>
@ -72,7 +59,17 @@ open <em>Show details</em> below — the last lines of the log say what went wro
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 failures = 0; var headingEl = document.getElementById('install-heading');
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' })
@ -80,7 +77,6 @@ open <em>Show details</em> below — the last lines of the log say what went wro
.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) {
@ -88,23 +84,21 @@ open <em>Show details</em> below — the last lines of the log say what went wro
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' || p.status === 'error') { if (p.status === 'done') {
// Reload so the server-rendered Done / error state (with // Reload so the server-rendered Done state (with the
// its Reboot or Try-again buttons) replaces the // Reboot button) replaces the running-state markup.
// running-state markup.
window.location.reload(); window.location.reload();
return; return;
} }
failures = 0; if (p.status === 'error') {
setTimeout(tick, 3000); bar.classList.add('progress-bar-error');
}) headingEl.textContent = HEADINGS.error;
.catch(function () { ledeEl.textContent = LEDES.error;
failures += 1; return;
if (failures >= 2) {
phaseEl.textContent = 'Lost connection to the installer — retrying…';
} }
setTimeout(tick, 3000); setTimeout(tick, 3000);
}); })
.catch(function () { 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">Check the settings below. Continuing will <strong>erase <code>{{ settings.boot_drive }}</code></strong> and install Furtka on it.</p> <p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p>
<div class="alert alert-warn" role="alert"> <div class="alert alert-warn">
Everything currently on this disk will be deleted and can't be recovered. This is destructive. All existing data on the selected boot drive will be lost.
</div> </div>
<div class="summary"> <div class="summary">
<table> <table>
<tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr> {% for k, v in settings.items() %}
<tr><td>Username</td><td>{{ settings.username }}</td></tr> <tr>
<tr><td>Password</td><td>{{ settings.password }}</td></tr> <td>{{ k.replace('_', ' ') }}</td>
<tr><td>Language</td><td>{{ language_label }}</td></tr> <td>{{ v }}</td>
<tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr> </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">Erase disk and install</button> <button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button>
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a> <a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a>
</div> </div>
</form> </form>
{% endblock %} {% endblock %}

View file

@ -10,7 +10,5 @@
<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 address doesn't open (some Android phones and older Windows PCs can't look up <code>.local</code> names), <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>
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>Name &amp; account</h1> <h1>Account &amp; hostname</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" role="alert"> <div class="alert alert-error">
<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">Server name</label> <label for="hostname">Hostname</label>
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" /> <input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" />
<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> <span class="hint">The name your server shows up as on your home network. Lowercase 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" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" /> <input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" />
<span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span> <span class="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 minlength="8" autocomplete="new-password" aria-describedby="password-hint" /> <input type="password" id="password" name="password" required autocomplete="new-password" />
<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> <span class="hint">At least 8 characters.</span>
</div> </div>
<div class="field"> <div class="field">
<label for="password2">Confirm password</label> <label for="password2">Confirm password</label>
@ -38,25 +38,15 @@
</div> </div>
<div class="field"> <div class="field">
<label for="language">Language</label> <label for="language">Language</label>
<select id="language" name="language" aria-describedby="language-hint"> <select id="language" name="language">
{% 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 a disk</button> <button type="submit" class="btn btn-primary">Next — pick boot drive</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,53 +1,39 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Step 2 — Disk · Furtka Installer{% endblock %} {% block title %}Step 2 — Boot drive · 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>Pick a disk</h1> <h1>Boot drive</h1>
<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> <p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p>
{% set usable = drives | selectattr("usable") | list %} {% if not drives %}
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %} <div class="alert alert-error">
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{% if not d.usable %} drive-disabled{% endif %}"> <label class="drive">
<input type="radio" name="boot_drive" value="{{ d.name }}" <input type="radio" name="boot_drive" value="{{ d.name }}"
{% if not d.usable %}disabled{% elif d.name == pick %}checked{% endif %} /> {% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} />
<span class="name">{{ d.name }}</span> <span class="name">{{ d.name }}</span>
{% if not selected and d.usable and loop.first %} {% if not selected 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>
{% if d.usable %}<span class="chip">{{ d.health_label }}</span>{% endif %} <span class="chip">{{ d.health_label }}</span>
</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 usable %}disabled{% endif %}>Next — review</button> <button type="submit" class="btn btn-primary" {% if not drives %}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,5 +1,4 @@
#!/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
@ -11,7 +10,6 @@ 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
@ -121,9 +119,7 @@ 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( return raster(svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height)
svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height
)
# ---------------------------------------------------------------- wordmark # ---------------------------------------------------------------- wordmark
@ -145,9 +141,7 @@ 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( parts.append(f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>')
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
@ -247,24 +241,21 @@ 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 = ( svgtxt = (f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">'
f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">' f'<g fill="{col}">{paths}</g></svg>')
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( ImageDraw.Draw(box).rounded_rectangle([0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc")
[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)