fix: bug scan — API hardening, safer disk picker, updater rollback, friendlier installer #19
29 changed files with 1445 additions and 359 deletions
50
CHANGELOG.md
50
CHANGELOG.md
|
|
@ -7,8 +7,50 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
|||
|
||||
## [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
|
||||
|
||||
- **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
|
||||
through `current`.** `_link_new_units()` enabled a timer a release
|
||||
introduced but never started it, so after the 26.19 → 26.20 update the
|
||||
|
|
@ -19,6 +61,14 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
|||
(installer-created links use `current`). Now: link via `current`, enable,
|
||||
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
|
||||
|
||||
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,15 @@
|
|||
</main>
|
||||
|
||||
<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
|
||||
// shape with the _HTML in furtka/api.py so the two logout
|
||||
// links behave identically.
|
||||
|
|
|
|||
|
|
@ -122,6 +122,15 @@
|
|||
</main>
|
||||
|
||||
<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.
|
||||
async function doLogout(ev) {
|
||||
ev.preventDefault();
|
||||
|
|
@ -162,6 +171,7 @@
|
|||
done: 'Update complete — reloading…',
|
||||
rolled_back: 'Update failed, rolled back to the previous version',
|
||||
rolled_back_manual: 'Rolled back manually',
|
||||
error: 'Update failed — nothing was changed',
|
||||
};
|
||||
|
||||
let pollHandle = null;
|
||||
|
|
@ -345,12 +355,13 @@
|
|||
if (!r.ok) return;
|
||||
const s = await r.json();
|
||||
const label = STAGE_LABELS[s.stage] || `Stage: ${s.stage}`;
|
||||
setStatus(label, s.stage === 'rolled_back');
|
||||
const failed = s.stage === 'rolled_back' || s.stage === 'error';
|
||||
setStatus(label, failed);
|
||||
if (s.stage === 'done') {
|
||||
clearInterval(pollHandle);
|
||||
clearTimeout(fallbackReloadHandle);
|
||||
setTimeout(() => location.reload(), 5000);
|
||||
} else if (s.stage === 'rolled_back') {
|
||||
} else if (failed) {
|
||||
clearInterval(pollHandle);
|
||||
clearTimeout(fallbackReloadHandle);
|
||||
if (s.reason) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ neutral ``ReleaseError`` for use in tests or standalone scripts.
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import http.client
|
||||
import json
|
||||
import shutil
|
||||
import tarfile
|
||||
|
|
@ -33,7 +34,9 @@ def forgejo_api(host: str, repo: str, path: str, *, error_cls: type = ReleaseErr
|
|||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read())
|
||||
except (urllib.error.URLError, json.JSONDecodeError) as e:
|
||||
except (OSError, http.client.HTTPException, json.JSONDecodeError) as e:
|
||||
# OSError covers URLError plus socket timeouts and resets while
|
||||
# reading the body, which urllib doesn't wrap.
|
||||
raise error_cls(f"forgejo api {url}: {e}") from e
|
||||
|
||||
|
||||
|
|
@ -43,7 +46,8 @@ def download(url: str, dest: Path, *, error_cls: type = ReleaseError) -> None:
|
|||
try:
|
||||
with urllib.request.urlopen(req, timeout=60) as resp, dest.open("wb") as f:
|
||||
shutil.copyfileobj(resp, f)
|
||||
except urllib.error.URLError as e:
|
||||
except (OSError, http.client.HTTPException) as e:
|
||||
# Includes disk full while writing `dest` and mid-body disconnects.
|
||||
raise error_cls(f"download {url}: {e}") from e
|
||||
|
||||
|
||||
|
|
|
|||
113
furtka/api.py
113
furtka/api.py
|
|
@ -17,12 +17,14 @@ stopgap.
|
|||
|
||||
import json
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
from http.cookies import SimpleCookie
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from furtka import auth, deps, dockerops, install_runner, installer, reconciler, sources
|
||||
from furtka.manifest import ManifestError, load_manifest
|
||||
from furtka.manifest import APP_NAME_RE, ManifestError, load_manifest
|
||||
from furtka.paths import apps_dir, static_www_dir
|
||||
from furtka.scanner import scan
|
||||
|
||||
|
|
@ -120,6 +122,15 @@ _HTML = """<!DOCTYPE html>
|
|||
</div>
|
||||
|
||||
<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) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
|
|
@ -765,7 +776,7 @@ def _do_get_settings(name):
|
|||
}
|
||||
|
||||
|
||||
_INSTALL_TERMINAL_STAGES = frozenset({"done", "error"})
|
||||
_INSTALL_TERMINAL_STAGES = install_runner.TERMINAL_STAGES
|
||||
|
||||
|
||||
def _do_install_plan(name):
|
||||
|
|
@ -819,8 +830,9 @@ def _do_install(name, settings=None, confirm_dependencies=False):
|
|||
# POST can sneak in during that tiny window. Reading the state
|
||||
# first closes that gap: as long as a previous install hasn't
|
||||
# written "done" or "error", we refuse.
|
||||
install_runner.clear_stale_state()
|
||||
current_state = install_runner.read_state()
|
||||
current_stage = current_state.get("stage", "") if isinstance(current_state, dict) else ""
|
||||
current_stage = current_state.get("stage", "")
|
||||
if current_stage and current_stage not in _INSTALL_TERMINAL_STAGES:
|
||||
return 409, {
|
||||
"error": (
|
||||
|
|
@ -916,6 +928,7 @@ def _do_install(name, settings=None, confirm_dependencies=False):
|
|||
|
||||
def _do_install_status():
|
||||
"""Return the current install-state.json contents (or {})."""
|
||||
install_runner.clear_stale_state()
|
||||
return 200, install_runner.read_state()
|
||||
|
||||
|
||||
|
|
@ -1238,7 +1251,33 @@ def _parse_settings_body(payload):
|
|||
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):
|
||||
# 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):
|
||||
body = json.dumps(payload).encode()
|
||||
self.send_response(status)
|
||||
|
|
@ -1347,6 +1386,14 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
return self._json(400, {"error": "password is required"})
|
||||
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():
|
||||
# First-run setup path — create the admin account, then log
|
||||
# in. Require password2 so a typo doesn't lock the user out
|
||||
|
|
@ -1393,13 +1440,39 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
auth.SESSIONS.revoke(morsel.value)
|
||||
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
|
||||
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 ------------------
|
||||
if self.path in ("/login", "/login/"):
|
||||
# Already authed? Skip straight to the app list.
|
||||
if self._current_session() is not None:
|
||||
return self._redirect("/apps")
|
||||
return self._html(200, _render_login_html(auth.setup_needed()))
|
||||
try:
|
||||
setup = auth.setup_needed()
|
||||
except auth.UsersFileError:
|
||||
traceback.print_exc()
|
||||
return self._html(503, f"<h1>Login unavailable</h1><p>{_USERS_FILE_BROKEN}</p>")
|
||||
return self._html(200, _render_login_html(setup))
|
||||
|
||||
# --- Auth guard for everything below -------------------------
|
||||
session = self._current_session()
|
||||
|
|
@ -1447,14 +1520,26 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
# /api/apps/<name>/settings
|
||||
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
||||
name = self.path[len("/api/apps/") : -len("/settings")]
|
||||
if "/" in name or not name:
|
||||
if not _valid_app_name(name):
|
||||
return self._json(400, {"error": "invalid app name"})
|
||||
status, body = _do_get_settings(name)
|
||||
return self._json(status, body)
|
||||
self._json(404, {"error": "not found"})
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
def _route_post(self):
|
||||
# Browsers tag every request with where it came from. Only accept
|
||||
# state changes from our own pages — an app on another port of the
|
||||
# same host counts as "same-site" and would pass SameSite=Strict.
|
||||
# Old browsers that don't send the header fall back to the cookie.
|
||||
fetch_site = self.headers.get("Sec-Fetch-Site")
|
||||
if fetch_site is not None and fetch_site not in ("same-origin", "none"):
|
||||
return self._json(403, {"error": "cross-site request refused"})
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
return self._json(400, {"error": "invalid Content-Length"})
|
||||
if length < 0 or length > MAX_BODY_BYTES:
|
||||
return self._json(413, {"error": "request body too large"})
|
||||
raw = self.rfile.read(length) if length else b""
|
||||
try:
|
||||
payload = json.loads(raw.decode()) if raw else {}
|
||||
|
|
@ -1473,10 +1558,15 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
if self._current_session() is None:
|
||||
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
|
||||
if self.path.startswith("/api/apps/") and self.path.endswith("/settings"):
|
||||
name = self.path[len("/api/apps/") : -len("/settings")]
|
||||
if "/" in name or not name:
|
||||
if not _valid_app_name(name):
|
||||
return self._json(400, {"error": "invalid app name"})
|
||||
settings = _parse_settings_body(payload)
|
||||
if settings is False or settings is None:
|
||||
|
|
@ -1487,7 +1577,7 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
# Per-app image update: /api/apps/<name>/update
|
||||
if self.path.startswith("/api/apps/") and self.path.endswith("/update"):
|
||||
name = self.path[len("/api/apps/") : -len("/update")]
|
||||
if "/" in name or not name:
|
||||
if not _valid_app_name(name):
|
||||
return self._json(400, {"error": "invalid app name"})
|
||||
status, body = _do_update(name)
|
||||
return self._json(status, body)
|
||||
|
|
@ -1519,6 +1609,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
name = payload.get("name")
|
||||
if not isinstance(name, str) or not name:
|
||||
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":
|
||||
status, body = _do_install_plan(name)
|
||||
|
|
@ -1541,7 +1633,8 @@ class _Handler(BaseHTTPRequestHandler):
|
|||
|
||||
def serve(host: str = "127.0.0.1", port: int = 7000) -> None:
|
||||
"""Run the API server. Blocks forever; exits on SIGINT/SIGTERM."""
|
||||
server = HTTPServer((host, port), _Handler)
|
||||
server = ThreadingHTTPServer((host, port), _Handler)
|
||||
server.daemon_threads = True
|
||||
print(f"Furtka API listening on http://{host}:{port}/")
|
||||
try:
|
||||
server.serve_forever()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from __future__ import annotations
|
|||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import secrets
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
|
|
@ -46,27 +47,33 @@ def verify_password(plain: str, hashed: str) -> bool:
|
|||
return _verify_password(plain, hashed)
|
||||
|
||||
|
||||
class UsersFileError(RuntimeError):
|
||||
"""users.json exists but can't be read or parsed."""
|
||||
|
||||
|
||||
def load_users() -> dict:
|
||||
"""Return the users dict, or {} if the file is missing or empty.
|
||||
"""Return the users dict, or {} if the file is missing.
|
||||
|
||||
Missing-file is the expected state on first boot and on upgrades from
|
||||
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()
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
raw = path.read_text()
|
||||
except OSError:
|
||||
return {}
|
||||
if not raw.strip():
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except (OSError, UnicodeDecodeError) as e:
|
||||
raise UsersFileError(f"{path} is unreadable: {e}") from e
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
except json.JSONDecodeError as e:
|
||||
raise UsersFileError(f"{path} is corrupt: {e}") from e
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
raise UsersFileError(f"{path} is corrupt: not a JSON object")
|
||||
return data
|
||||
|
||||
|
||||
|
|
@ -80,7 +87,12 @@ def save_users(users: dict) -> None:
|
|||
path = users_file()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(users, indent=2) + "\n")
|
||||
with open(tmp, "w") as fh:
|
||||
fh.write(json.dumps(users, indent=2) + "\n")
|
||||
# A power cut right after rename() can otherwise leave a zero-length
|
||||
# users.json, which load_users() refuses — locking the owner out.
|
||||
fh.flush()
|
||||
os.fsync(fh.fileno())
|
||||
tmp.chmod(0o600)
|
||||
tmp.replace(path)
|
||||
|
||||
|
|
|
|||
|
|
@ -216,6 +216,9 @@ def sync_catalog() -> CatalogCheck:
|
|||
except CatalogError as e:
|
||||
write_state("error", error=str(e))
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -91,9 +91,53 @@ def write_state(stage: str, **extra) -> None:
|
|||
|
||||
def read_state() -> dict:
|
||||
try:
|
||||
return json.loads(state_path().read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = json.loads(state_path().read_text())
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import shutil
|
|||
from pathlib import Path
|
||||
|
||||
from furtka import sources
|
||||
from furtka.manifest import Manifest, ManifestError, load_manifest
|
||||
from furtka.manifest import APP_NAME_RE, Manifest, ManifestError, load_manifest
|
||||
from furtka.paths import apps_dir
|
||||
|
||||
# Values that an app's .env.example may use as obvious "fill me in" markers.
|
||||
|
|
@ -35,19 +35,26 @@ class InstallError(RuntimeError):
|
|||
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]:
|
||||
if not env_path.exists():
|
||||
return []
|
||||
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
|
||||
return _placeholder_values(_read_env(env_path))
|
||||
|
||||
|
||||
def _is_denied_system_path(resolved: str) -> bool:
|
||||
|
|
@ -59,16 +66,13 @@ def _is_denied_system_path(resolved: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]:
|
||||
def _path_setting_errors(m: Manifest, values: dict[str, str]) -> list[str]:
|
||||
"""Validate the filesystem paths named by `path`-type settings.
|
||||
|
||||
Returns one human-readable message per offending setting. Empty values
|
||||
on non-required settings are allowed — the required-field check in the
|
||||
caller already refuses blanks on required fields before write.
|
||||
"""
|
||||
if not env_path.exists():
|
||||
return []
|
||||
values = _read_env(env_path)
|
||||
errors: list[str] = []
|
||||
for s in m.settings:
|
||||
if s.type != "path":
|
||||
|
|
@ -97,10 +101,35 @@ def _path_setting_errors(m: Manifest, env_path: Path) -> list[str]:
|
|||
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:
|
||||
# Quote values that contain whitespace, quotes, or shell metacharacters so
|
||||
# docker-compose's env substitution reads them back intact. Simple values
|
||||
# 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\"'$`\\#"):
|
||||
escaped = v.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
|
@ -167,7 +196,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
|
|||
if key not in declared:
|
||||
raise InstallError(f"{m.name}: unknown setting {key!r}")
|
||||
|
||||
target = apps_dir() / m.name
|
||||
target = app_dir(m.name)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for item in src.iterdir():
|
||||
|
|
@ -216,6 +245,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
|
|||
for s in m.settings:
|
||||
if s.required and not merged.get(s.name):
|
||||
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
||||
_validate_settings(m, merged)
|
||||
write_env(env, merged)
|
||||
elif not env.exists() and env_example.exists():
|
||||
# First install with no settings and no .env shipped: bootstrap from
|
||||
|
|
@ -235,7 +265,7 @@ def install_from(src: Path, settings: dict[str, str] | None = None) -> Path:
|
|||
f"file and re-run `furtka app install {m.name}`."
|
||||
)
|
||||
|
||||
path_errors = _path_setting_errors(m, env)
|
||||
path_errors = _path_setting_errors(m, read_env_values(env))
|
||||
if path_errors:
|
||||
raise InstallError(f"{m.name}: {'; '.join(path_errors)}")
|
||||
|
||||
|
|
@ -256,10 +286,11 @@ def parse_env_text(text: str) -> dict[str, str]:
|
|||
continue
|
||||
key, _, value = line.partition("=")
|
||||
value = value.strip()
|
||||
if (value.startswith('"') and value.endswith('"')) or (
|
||||
value.startswith("'") and value.endswith("'")
|
||||
):
|
||||
if len(value) >= 2 and value.startswith('"') and value.endswith('"'):
|
||||
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
|
||||
return out
|
||||
|
||||
|
|
@ -284,7 +315,7 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
|
|||
apps only. Returns the target folder; caller is expected to run
|
||||
reconcile to restart the containers.
|
||||
"""
|
||||
target = apps_dir() / name
|
||||
target = app_dir(name)
|
||||
manifest_path = target / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
raise InstallError(f"{name!r} is not installed")
|
||||
|
|
@ -316,13 +347,8 @@ def update_env(name: str, settings: dict[str, str]) -> Path:
|
|||
for s in m.settings:
|
||||
if s.required and not merged.get(s.name):
|
||||
raise InstallError(f"{m.name}: setting {s.name!r} is required")
|
||||
_validate_settings(m, 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
|
||||
|
||||
|
||||
|
|
@ -353,7 +379,7 @@ def remove(name: str) -> Path:
|
|||
|
||||
Caller is responsible for stopping the compose project first.
|
||||
"""
|
||||
target = apps_dir() / name
|
||||
target = app_dir(name)
|
||||
if not target.exists():
|
||||
raise InstallError(f"{name!r} is not installed")
|
||||
shutil.rmtree(target)
|
||||
|
|
|
|||
|
|
@ -175,8 +175,10 @@ def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
|||
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
|
||||
|
||||
name = raw["name"]
|
||||
if not isinstance(name, str) or not name:
|
||||
raise ManifestError(f"{path}: name must be a non-empty string")
|
||||
if not isinstance(name, str) or not APP_NAME_RE.fullmatch(name):
|
||||
raise ManifestError(
|
||||
f"{path}: name must be lowercase letters, digits, '-' or '_' (got {name!r})"
|
||||
)
|
||||
if expected_name is not None and name != expected_name:
|
||||
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ the updater at a tmpdir.
|
|||
from __future__ import annotations
|
||||
|
||||
import fcntl
|
||||
import http.client
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
|
@ -265,9 +266,10 @@ def write_state(stage: str, **extra) -> None:
|
|||
|
||||
def read_state() -> dict:
|
||||
try:
|
||||
return json.loads(state_path().read_text())
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
data = json.loads(state_path().read_text())
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def acquire_lock():
|
||||
|
|
@ -318,13 +320,29 @@ def _health_check(url: str, deadline_s: float = 30.0) -> bool:
|
|||
# check. 5xx → genuinely broken, don't accept.
|
||||
if 400 <= e.code < 500:
|
||||
return True
|
||||
except urllib.error.URLError:
|
||||
# Connection refused / DNS / timeout → not up yet, retry.
|
||||
except (OSError, http.client.HTTPException):
|
||||
# Connection refused / reset / timeout (URLError is an OSError;
|
||||
# a read timeout or RemoteDisconnected is not wrapped by urllib)
|
||||
# → not up yet, retry.
|
||||
pass
|
||||
time.sleep(1)
|
||||
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]:
|
||||
"""Download + verify the tarball. Returns (tarball_path, version)."""
|
||||
if not check.tarball_url or not check.sha256_url:
|
||||
|
|
@ -369,15 +387,11 @@ def apply_update(tarball: Path, version: str) -> None:
|
|||
target.chmod(0o755)
|
||||
|
||||
write_state("swapping", latest=version)
|
||||
previous = None
|
||||
if current.is_symlink():
|
||||
previous = os.readlink(current)
|
||||
current.unlink()
|
||||
previous = os.readlink(current) if current.is_symlink() else None
|
||||
old_caddyfile = _CADDYFILE_LIVE.read_text() if _CADDYFILE_LIVE.is_file() else None
|
||||
try:
|
||||
current.symlink_to(target)
|
||||
_swap_symlink(current, target)
|
||||
except OSError as e:
|
||||
if previous:
|
||||
current.symlink_to(previous)
|
||||
raise UpdateError(f"symlink swap failed: {e}") from e
|
||||
|
||||
write_state("restarting", latest=version)
|
||||
|
|
@ -393,27 +407,41 @@ def apply_update(tarball: Path, version: str) -> None:
|
|||
_run(["systemctl", "daemon-reload"])
|
||||
_run(["systemctl", "try-restart", "furtka-reconcile.service"])
|
||||
_run(["systemctl", "restart", "furtka-api.service"])
|
||||
except UpdateError as e:
|
||||
_rollback(previous, version, f"service restart failed: {e}")
|
||||
raise
|
||||
except (UpdateError, OSError) as e:
|
||||
# OSError: e.g. disk full or EACCES writing the Caddyfile — the new
|
||||
# version is already live, so this must roll back too.
|
||||
_rollback(previous, version, f"service restart failed: {e}", old_caddyfile)
|
||||
raise UpdateError(f"service restart failed: {e} — rolled back") from e
|
||||
|
||||
write_state("verifying", latest=version)
|
||||
ok = _health_check("http://127.0.0.1:7000/api/apps", deadline_s=30.0)
|
||||
if not ok:
|
||||
_rollback(previous, version, "health check failed after restart")
|
||||
_rollback(previous, version, "health check failed after restart", old_caddyfile)
|
||||
raise UpdateError("new version failed health check — rolled back")
|
||||
|
||||
write_state("done", version=version)
|
||||
|
||||
|
||||
def _rollback(previous_target: str | None, failed_version: str, reason: str) -> None:
|
||||
def _rollback(
|
||||
previous_target: str | None,
|
||||
failed_version: str,
|
||||
reason: str,
|
||||
old_caddyfile: str | None = None,
|
||||
) -> None:
|
||||
current = current_symlink()
|
||||
if previous_target:
|
||||
if current.is_symlink():
|
||||
current.unlink()
|
||||
current.symlink_to(previous_target)
|
||||
_swap_symlink(current, previous_target)
|
||||
# The new release may have shipped Caddy routes the old code
|
||||
# doesn't serve; put the previous Caddyfile back before reloading.
|
||||
if old_caddyfile is not None:
|
||||
try:
|
||||
_CADDYFILE_LIVE.write_text(old_caddyfile)
|
||||
except OSError:
|
||||
pass
|
||||
# Best-effort restart on the previous target — if it fails too the
|
||||
# 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)
|
||||
write_state(
|
||||
"rolled_back",
|
||||
|
|
@ -430,6 +458,7 @@ def run_update() -> UpdateCheck:
|
|||
UpdateError on any failure; the state file records the stage.
|
||||
"""
|
||||
with acquire_lock():
|
||||
try:
|
||||
check = check_update()
|
||||
if not check.update_available:
|
||||
write_state("done", version=check.current, note="already up to date")
|
||||
|
|
@ -437,6 +466,17 @@ def run_update() -> UpdateCheck:
|
|||
tarball, version = prepare_update(check)
|
||||
apply_update(tarball, version)
|
||||
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:
|
||||
|
|
@ -456,8 +496,7 @@ def rollback() -> str:
|
|||
raise UpdateError("no other version slots available to roll back to")
|
||||
target_name = candidates[0]
|
||||
target = versions_dir() / target_name
|
||||
current.unlink()
|
||||
current.symlink_to(target)
|
||||
_swap_symlink(current, target)
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=False)
|
||||
subprocess.run(["systemctl", "restart", "furtka-api.service"], check=False)
|
||||
write_state("rolled_back_manual", restored_to=target_name)
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ new=$(
|
|||
fi
|
||||
echo
|
||||
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 " Need a shell? Log in as root (no password)."
|
||||
echo
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""Smoke test the actual HTTP server with a real socket, urllib client."""
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler) # port 0 → ephemeral
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
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):
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
|
|
@ -459,7 +459,7 @@ def test_http_post_install_unknown_app(fake_dirs, admin_session):
|
|||
|
||||
|
||||
def _start_server():
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
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):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
|
|
@ -1211,7 +1211,7 @@ def test_http_post_update_route(
|
|||
api._do_install("fileshare")
|
||||
update_docker_stubs["up_called"] = 0 # reset counter after install
|
||||
update_docker_stubs["local"] = {"samba": "sha256:NEW"}
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
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):
|
||||
_, bundled = fake_dirs
|
||||
_write_bundled(bundled, "fileshare", manifest=SETTINGS_MANIFEST)
|
||||
server = api.HTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
server = api.ThreadingHTTPServer(("127.0.0.1", 0), api._Handler)
|
||||
port = server.server_address[1]
|
||||
t = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
t.start()
|
||||
|
|
@ -1424,3 +1424,103 @@ def test_power_surfaces_systemd_run_missing(fake_dirs, monkeypatch):
|
|||
status, body = api._do_power({"action": "reboot"})
|
||||
assert status == 502
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -226,3 +226,167 @@ def test_log_page_running_state_has_no_restart_button(monkeypatch, tmp_path):
|
|||
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
|
||||
assert "next-steps" 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
|
||||
|
|
|
|||
|
|
@ -40,14 +40,15 @@ def test_load_users_returns_empty_when_missing(tmp_users_file):
|
|||
assert auth.load_users() == {}
|
||||
|
||||
|
||||
def test_load_users_returns_empty_on_junk(tmp_users_file):
|
||||
tmp_users_file.write_text("{not json")
|
||||
assert auth.load_users() == {}
|
||||
|
||||
|
||||
def test_load_users_returns_empty_on_non_dict(tmp_users_file):
|
||||
tmp_users_file.write_text("[]")
|
||||
assert auth.load_users() == {}
|
||||
@pytest.mark.parametrize("content", ["{not json", "[]", "", "\x00\x00"])
|
||||
def test_load_users_fails_closed_on_corrupt_file(tmp_users_file, content):
|
||||
"""A damaged users.json must not read as "no admin yet" — that would
|
||||
turn /login into an open create-admin form for anyone on the LAN."""
|
||||
tmp_users_file.write_text(content)
|
||||
with pytest.raises(auth.UsersFileError):
|
||||
auth.load_users()
|
||||
with pytest.raises(auth.UsersFileError):
|
||||
auth.setup_needed()
|
||||
|
||||
|
||||
def test_save_users_atomic_and_0600(tmp_users_file):
|
||||
|
|
|
|||
|
|
@ -349,3 +349,30 @@ def test_sync_catalog_failure_writes_error_state(catalog, monkeypatch):
|
|||
state = catalog.read_state()
|
||||
assert state["stage"] == "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")
|
||||
|
|
|
|||
|
|
@ -1,47 +1,63 @@
|
|||
import json
|
||||
|
||||
import drives
|
||||
import pytest
|
||||
from drives import (
|
||||
get_drive_type_label,
|
||||
MIN_DISK_GB,
|
||||
_boot_device_from_cmdline,
|
||||
classify_drive,
|
||||
format_size,
|
||||
get_drive_type_score,
|
||||
get_size_score,
|
||||
parse_lsblk_output,
|
||||
parse_size_gb,
|
||||
score_device,
|
||||
parse_lsblk_json,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_size_gb_terabytes():
|
||||
assert parse_size_gb("1T") == 1024.0
|
||||
GB = 1024**3
|
||||
|
||||
|
||||
def test_parse_size_gb_gigabytes():
|
||||
assert parse_size_gb("500G") == 500.0
|
||||
def _lsblk(*entries):
|
||||
"""Build lsblk -J output. Each entry: (name, size_gb, type, extra-dict)."""
|
||||
devs = []
|
||||
for name, size_gb, dev_type, *extra in entries:
|
||||
d = {
|
||||
"name": name,
|
||||
"size": int(size_gb * GB),
|
||||
"type": dev_type,
|
||||
"ro": False,
|
||||
"rm": False,
|
||||
"tran": "sata",
|
||||
"rota": True,
|
||||
}
|
||||
if extra:
|
||||
d.update(extra[0])
|
||||
devs.append(d)
|
||||
return json.dumps({"blockdevices": devs})
|
||||
|
||||
|
||||
def test_parse_size_gb_megabytes():
|
||||
assert parse_size_gb("2048M") == 2.0
|
||||
@pytest.fixture(autouse=True)
|
||||
def _smart_passed(monkeypatch):
|
||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||
|
||||
|
||||
def test_parse_size_gb_european_comma_decimal():
|
||||
assert parse_size_gb("1,5T") == 1.5 * 1024
|
||||
def _names(devices):
|
||||
return [d["name"] for d in devices]
|
||||
|
||||
|
||||
def test_parse_size_gb_empty_returns_none():
|
||||
assert parse_size_gb("") is None
|
||||
def test_classify_uses_transport_and_rotation():
|
||||
assert classify_drive("nvme0n1", tran="nvme", rota=False) == "NVMe"
|
||||
assert classify_drive("sda", tran="sata", rota=False) == "SSD"
|
||||
assert classify_drive("sda", tran="sata", rota=True) == "HDD"
|
||||
assert classify_drive("mmcblk0", tran="", rota=False) == "eMMC"
|
||||
assert classify_drive("sdb", tran="usb", rota=False) == "USB"
|
||||
assert classify_drive("sdc", tran="", rota=True, removable=True) == "USB"
|
||||
# SD cards in a built-in reader are removable mmc — still eMMC/mmc class.
|
||||
assert classify_drive("mmcblk1", tran="", rota=False, removable=True) == "eMMC"
|
||||
|
||||
|
||||
def test_parse_size_gb_unknown_unit_returns_none():
|
||||
assert parse_size_gb("500K") is None
|
||||
|
||||
|
||||
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_type_scores_rank_internal_over_usb():
|
||||
assert get_drive_type_score("NVMe") > get_drive_type_score("SSD")
|
||||
assert get_drive_type_score("SSD") > get_drive_type_score("HDD")
|
||||
assert get_drive_type_score("HDD") > get_drive_type_score("USB")
|
||||
|
||||
|
||||
def test_size_score_bands():
|
||||
|
|
@ -51,67 +67,145 @@ def test_size_score_bands():
|
|||
assert get_size_score(1024) == 10
|
||||
|
||||
|
||||
def test_score_device_sums_type_and_size(monkeypatch):
|
||||
import drives
|
||||
|
||||
monkeypatch.setattr(drives, "get_drive_health", lambda _: 10)
|
||||
assert score_device("/dev/nvme0n1", 1024) == 15 + 10 + 10
|
||||
assert score_device("/dev/sda", 64) == 5 + 10 + 5
|
||||
def test_format_size():
|
||||
assert format_size(500 * GB) == "500 GB"
|
||||
assert format_size(1024 * GB) == "1 TB"
|
||||
assert format_size(int(1.5 * 1024 * GB)) == "1.5 TB"
|
||||
assert format_size(int(7.5 * GB)) == "7.5 GB"
|
||||
|
||||
|
||||
def test_parse_lsblk_drops_loop_and_rom(monkeypatch):
|
||||
import drives
|
||||
|
||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||
output = "loop0 2.5G loop\nsr0 1024M rom\nsda 500G disk\nnvme0n1 1T disk\n"
|
||||
devices = parse_lsblk_output(output)
|
||||
names = [d["name"] for d in devices]
|
||||
assert names == ["/dev/nvme0n1", "/dev/sda"]
|
||||
def test_drops_loop_rom_zram_and_emmc_boot_areas():
|
||||
out = _lsblk(
|
||||
("loop0", 2.5, "loop"),
|
||||
("sr0", 1, "rom"),
|
||||
("zram0", 4, "disk"),
|
||||
("mmcblk0boot0", 0.004, "disk"),
|
||||
("mmcblk0boot1", 0.004, "disk"),
|
||||
("mmcblk0rpmb", 0.004, "disk"),
|
||||
("mmcblk0", 64, "disk", {"tran": "", "rota": False}),
|
||||
)
|
||||
assert _names(parse_lsblk_json(out)) == ["/dev/mmcblk0"]
|
||||
|
||||
|
||||
def test_parse_lsblk_attaches_human_labels(monkeypatch):
|
||||
import drives
|
||||
|
||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||
output = "nvme0n1 1T disk\n"
|
||||
[dev] = parse_lsblk_output(output)
|
||||
assert dev["type_label"] == "NVMe"
|
||||
assert dev["health_label"] == "Healthy"
|
||||
def test_drops_empty_card_reader_slots_and_read_only_disks():
|
||||
out = _lsblk(
|
||||
("sdc", 0, "disk", {"rm": True, "tran": "usb"}),
|
||||
("sdd", 500, "disk", {"ro": True}),
|
||||
("sda", 500, "disk"),
|
||||
)
|
||||
assert _names(parse_lsblk_json(out)) == ["/dev/sda"]
|
||||
|
||||
|
||||
def test_parse_lsblk_surfaces_smart_warning(monkeypatch):
|
||||
import drives
|
||||
def test_ssd_beats_bigger_hdd():
|
||||
# Regression: sdX SSDs used to be labelled HDD, so the 2 TB spinning disk
|
||||
# won the recommendation over the 256 GB SSD.
|
||||
out = _lsblk(
|
||||
("sda", 2048, "disk", {"rota": True}),
|
||||
("sdb", 256, "disk", {"rota": False}),
|
||||
)
|
||||
devices = parse_lsblk_json(out)
|
||||
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
|
||||
assert [d["type_label"] for d in devices] == ["SSD", "HDD"]
|
||||
|
||||
|
||||
def test_usb_disk_ranked_after_internal():
|
||||
out = _lsblk(
|
||||
("sda", 32, "disk", {"tran": "usb", "rota": False, "rm": True}),
|
||||
("mmcblk0", 32, "disk", {"tran": "", "rota": False}),
|
||||
)
|
||||
devices = parse_lsblk_json(out)
|
||||
assert _names(devices) == ["/dev/mmcblk0", "/dev/sda"]
|
||||
assert devices[1]["warning"]
|
||||
|
||||
|
||||
def test_too_small_disk_listed_but_unusable_and_last():
|
||||
out = _lsblk(
|
||||
("sda", MIN_DISK_GB - 1, "disk", {"rota": False}),
|
||||
("sdb", 500, "disk"),
|
||||
)
|
||||
devices = parse_lsblk_json(out)
|
||||
assert _names(devices) == ["/dev/sdb", "/dev/sda"]
|
||||
assert devices[0]["usable"] is True
|
||||
assert devices[1]["usable"] is False
|
||||
assert str(MIN_DISK_GB) in devices[1]["reason"]
|
||||
|
||||
|
||||
def test_bios_warns_about_disks_over_2tb():
|
||||
out = _lsblk(("sda", 4096, "disk"))
|
||||
[bios] = parse_lsblk_json(out, uefi=False)
|
||||
[efi] = parse_lsblk_json(out, uefi=True)
|
||||
assert "2 TB" in bios["warning"]
|
||||
assert efi["warning"] == ""
|
||||
|
||||
|
||||
def test_surfaces_smart_warning(monkeypatch):
|
||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "failed")
|
||||
[dev] = parse_lsblk_output("sda 500G disk\n")
|
||||
[dev] = parse_lsblk_json(_lsblk(("sda", 500, "disk")))
|
||||
assert dev["health_label"] == "SMART warning"
|
||||
|
||||
|
||||
def test_drive_type_label_nvme_ssd_hdd():
|
||||
assert get_drive_type_label("/dev/nvme0n1") == "NVMe"
|
||||
assert get_drive_type_label("/dev/ssd0") == "SSD"
|
||||
assert get_drive_type_label("/dev/sda") == "HDD"
|
||||
def test_accepts_old_util_linux_string_flags():
|
||||
out = json.dumps(
|
||||
{
|
||||
"blockdevices": [
|
||||
{
|
||||
"name": "sda",
|
||||
"size": str(500 * GB),
|
||||
"type": "disk",
|
||||
"ro": "0",
|
||||
"rm": "0",
|
||||
"tran": "sata",
|
||||
"rota": "0",
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
[dev] = parse_lsblk_json(out)
|
||||
assert dev["type_label"] == "SSD"
|
||||
|
||||
|
||||
def test_parse_lsblk_handles_empty_output():
|
||||
assert parse_lsblk_output("") == []
|
||||
def test_handles_empty_and_garbage_output():
|
||||
assert parse_lsblk_json("") == []
|
||||
assert parse_lsblk_json("not json") == []
|
||||
|
||||
|
||||
def test_parse_lsblk_drops_boot_usb(monkeypatch):
|
||||
import drives
|
||||
|
||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||
output = "sda 500G disk\nsdb 16G disk\nnvme0n1 1T disk\n"
|
||||
devices = parse_lsblk_output(output, boot_disk="sdb")
|
||||
names = [d["name"] for d in devices]
|
||||
assert "/dev/sdb" not in names
|
||||
assert names == ["/dev/nvme0n1", "/dev/sda"]
|
||||
def test_drops_boot_usb():
|
||||
out = _lsblk(
|
||||
("sda", 500, "disk"),
|
||||
("sdb", 16, "disk", {"tran": "usb"}),
|
||||
("nvme0n1", 1024, "disk", {"tran": "nvme", "rota": False}),
|
||||
)
|
||||
assert _names(parse_lsblk_json(out, boot_disk="sdb")) == ["/dev/nvme0n1", "/dev/sda"]
|
||||
|
||||
|
||||
def test_parse_lsblk_no_boot_disk_keeps_all(monkeypatch):
|
||||
import drives
|
||||
def test_boot_device_from_cmdline(monkeypatch):
|
||||
calls = []
|
||||
|
||||
monkeypatch.setattr(drives, "_smart_status", lambda _: "passed")
|
||||
output = "sda 500G disk\nsdb 16G disk\n"
|
||||
names = [d["name"] for d in parse_lsblk_output(output, boot_disk=None)]
|
||||
assert set(names) == {"/dev/sda", "/dev/sdb"}
|
||||
class R:
|
||||
returncode = 0
|
||||
stdout = "/dev/sdb1\n"
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -478,3 +478,35 @@ def test_parse_hook_output_furtka_json_rejects_bad_payload(runner):
|
|||
def test_parse_hook_output_furtka_json_invalid_json(runner):
|
||||
with pytest.raises(runner.InstallRunnerError, match="invalid FURTKA_JSON"):
|
||||
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() == {}
|
||||
|
|
|
|||
|
|
@ -462,3 +462,58 @@ def test_install_plan_skips_already_installed(tmp_path, fake_dirs, monkeypatch):
|
|||
)
|
||||
installer.install_plan(plan)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -463,3 +463,79 @@ def test_check_update_raises_when_no_releases_published(updater, monkeypatch):
|
|||
monkeypatch.setattr(updater, "_forgejo_api", lambda path: [])
|
||||
with pytest.raises(updater.UpdateError, match="no releases"):
|
||||
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
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import os
|
|||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import traceback
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -76,6 +78,35 @@ settings = {
|
|||
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
||||
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
|
||||
# (substring to search for in the archinstall log, progress percent when
|
||||
# reached, user-facing label). Pick the furthest phase whose marker is
|
||||
|
|
@ -108,7 +139,13 @@ PROGRESS_ERROR_HINTS = [
|
|||
]
|
||||
|
||||
|
||||
def parse_install_progress(log):
|
||||
def parse_install_progress(log, exit_code=None):
|
||||
"""Progress dict from the install log.
|
||||
|
||||
`exit_code` is archinstall's return code once the process has ended
|
||||
(None while running or unknown). A non-zero exit is an error even when
|
||||
the log has no Traceback — OOM kills and signals print nothing.
|
||||
"""
|
||||
percent = 2
|
||||
phase = "Starting up…"
|
||||
for marker, pct, label in PROGRESS_PHASES:
|
||||
|
|
@ -118,7 +155,7 @@ def parse_install_progress(log):
|
|||
|
||||
if percent >= 100:
|
||||
status = "done"
|
||||
elif any(m in log for m in PROGRESS_ERROR_MARKERS):
|
||||
elif any(m in log for m in PROGRESS_ERROR_MARKERS) or exit_code not in (None, 0):
|
||||
status = "error"
|
||||
phase = "Installation failed — open Show details below"
|
||||
for marker, hint in PROGRESS_ERROR_HINTS:
|
||||
|
|
@ -127,24 +164,39 @@ def parse_install_progress(log):
|
|||
break
|
||||
else:
|
||||
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}
|
||||
|
||||
|
||||
def validate_step1(form):
|
||||
errors = []
|
||||
# Lowercase instead of rejecting "Furtka" / "Daniel" — Linux needs
|
||||
# lowercase, people type names the way they write them.
|
||||
values = {
|
||||
"hostname": form.get("hostname", "").strip(),
|
||||
"username": form.get("username", "").strip(),
|
||||
"hostname": form.get("hostname", "").strip().lower(),
|
||||
"username": form.get("username", "").strip().lower(),
|
||||
"password": form.get("password", ""),
|
||||
"language": form.get("language", ""),
|
||||
}
|
||||
password2 = form.get("password2", "")
|
||||
|
||||
if not HOSTNAME_RE.match(values["hostname"]):
|
||||
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).")
|
||||
errors.append(
|
||||
"The server name can only use letters, digits and hyphens (no spaces, max 63)."
|
||||
)
|
||||
if not USERNAME_RE.match(values["username"]):
|
||||
errors.append("Username must start with a letter or underscore, lowercase only.")
|
||||
errors.append(
|
||||
"The username must start with a letter and can only use letters, digits, - and _."
|
||||
)
|
||||
elif values["username"] in RESERVED_USERNAMES:
|
||||
errors.append(f"“{values['username']}” is reserved by the system — please pick another.")
|
||||
if len(values["password"]) < 8:
|
||||
errors.append("Password must be at least 8 characters.")
|
||||
if values["password"] != password2:
|
||||
|
|
@ -438,6 +490,16 @@ def _post_install_commands(hostname, admin_username, admin_password):
|
|||
# Initial status.json so Caddy doesn't 404 before furtka-status fires.
|
||||
_write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")),
|
||||
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>/,
|
||||
# symlink current, install wrapper, systemctl-link unit files.
|
||||
*_resource_manager_commands(),
|
||||
|
|
@ -570,6 +632,16 @@ def spawn_archinstall(config_path, creds_path, log_path):
|
|||
)
|
||||
|
||||
|
||||
@app.before_request
|
||||
def _stay_on_progress_while_installing():
|
||||
# While archinstall runs, the wizard pages would let a second browser
|
||||
# (or Back) edit settings that no longer apply — show the progress.
|
||||
running = _install_proc is not None and _install_proc.poll() is None
|
||||
if running and request.endpoint in ("install_step_1", "install_step_2", "install_overview"):
|
||||
return redirect(url_for("install_log_view"))
|
||||
return None
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def home():
|
||||
return redirect(url_for("install_step_1"))
|
||||
|
|
@ -594,32 +666,89 @@ def install_step_1():
|
|||
)
|
||||
|
||||
|
||||
# Size/type of the disk picked in step 2, for the confirm page (re-probing
|
||||
# would re-run smartctl on every disk).
|
||||
_selected_drive: dict = {}
|
||||
|
||||
|
||||
@app.route("/install/step2", methods=["GET", "POST"])
|
||||
def install_step_2():
|
||||
drives = list_scored_devices()
|
||||
error = ""
|
||||
if request.method == "POST":
|
||||
boot_drive = request.form.get("boot_drive", "").strip()
|
||||
if boot_drive:
|
||||
# Only accept a disk we actually offered — never a free-form path.
|
||||
picked = next((d for d in drives if d["name"] == boot_drive and d["usable"]), None)
|
||||
if picked:
|
||||
settings["boot_drive"] = boot_drive
|
||||
_selected_drive.clear()
|
||||
_selected_drive.update(picked)
|
||||
return redirect(url_for("install_overview"))
|
||||
error = "Please pick one of the disks below."
|
||||
return render_template(
|
||||
"install/step2.html",
|
||||
drives=list_scored_devices(),
|
||||
drives=drives,
|
||||
selected=settings.get("boot_drive", ""),
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/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 ""}
|
||||
return render_template("install/overview.html", settings=masked)
|
||||
drive = _selected_drive if _selected_drive.get("name") == settings["boot_drive"] else None
|
||||
language = LANGUAGES.get(settings["language"], {}).get("label", settings["language"])
|
||||
return render_template(
|
||||
"install/overview.html", settings=masked, drive=drive, language_label=language
|
||||
)
|
||||
|
||||
|
||||
# The running archinstall process, so a second click (double-submit, Back +
|
||||
# Install again, a second browser) can't start another install on the same
|
||||
# disk, and so the progress page can see an exit without a Traceback.
|
||||
_install_proc = None
|
||||
_install_lock = threading.Lock()
|
||||
|
||||
|
||||
def _install_exit_code():
|
||||
return None if _install_proc is None else _install_proc.poll()
|
||||
|
||||
|
||||
def _read_install_log():
|
||||
log = INSTALL_LOG.read_text(errors="replace") if INSTALL_LOG.exists() else ""
|
||||
code = _install_exit_code()
|
||||
if code not in (None, 0):
|
||||
log += f"\n\narchinstall stopped unexpectedly (exit code {code}).\n"
|
||||
return log, code
|
||||
|
||||
|
||||
@app.route("/install/run", methods=["POST"])
|
||||
def install_run():
|
||||
if not settings["boot_drive"] or not settings["username"] or not settings["password"]:
|
||||
return redirect(url_for("install_step_1"))
|
||||
config_path, creds_path = write_install_files(settings, STATE_DIR)
|
||||
with _install_lock:
|
||||
if _install_proc is not None and _install_proc.poll() is None:
|
||||
return redirect(url_for("install_log_view"))
|
||||
return _start_install()
|
||||
|
||||
|
||||
def _start_install():
|
||||
global _install_proc
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
INSTALL_LOG.write_bytes(b"")
|
||||
try:
|
||||
config_path, creds_path = write_install_files(settings, STATE_DIR)
|
||||
except Exception:
|
||||
# archinstall's disk probe raises on disks it can't parse (broken
|
||||
# GPT, odd card readers). Put the traceback where the progress page
|
||||
# finds it instead of a bare "Internal Server Error".
|
||||
with INSTALL_LOG.open("a") as fh:
|
||||
fh.write(
|
||||
f"Could not read the layout of {settings['boot_drive']}.\n" + traceback.format_exc()
|
||||
)
|
||||
return redirect(url_for("install_log_view"))
|
||||
if os.environ.get("FURTKA_DRY_RUN") == "1":
|
||||
INSTALL_LOG.write_text(
|
||||
f"DRY RUN: would exec archinstall --config {config_path} "
|
||||
|
|
@ -631,33 +760,33 @@ def install_run():
|
|||
# while those hold the partitions open (first hardware bench, 26.19).
|
||||
with INSTALL_LOG.open("a") as fh:
|
||||
fh.write("\n".join(release_disk(settings["boot_drive"])) + "\n\n")
|
||||
spawn_archinstall(config_path, creds_path, INSTALL_LOG)
|
||||
_install_proc = spawn_archinstall(config_path, creds_path, INSTALL_LOG)
|
||||
return redirect(url_for("install_log_view"))
|
||||
|
||||
|
||||
@app.route("/install/log")
|
||||
def install_log_view():
|
||||
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||
log, code = _read_install_log()
|
||||
return render_template(
|
||||
"install/log.html",
|
||||
log=log,
|
||||
progress=parse_install_progress(log),
|
||||
progress=parse_install_progress(log, code),
|
||||
hostname=settings["hostname"],
|
||||
)
|
||||
|
||||
|
||||
@app.route("/install/log.json")
|
||||
def install_log_json():
|
||||
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||
return jsonify(log=log, progress=parse_install_progress(log))
|
||||
log, code = _read_install_log()
|
||||
return jsonify(log=log, progress=parse_install_progress(log, code))
|
||||
|
||||
|
||||
@app.route("/install/reboot", methods=["POST"])
|
||||
def install_reboot():
|
||||
# Only allow rebooting once the install has actually finished — we don't
|
||||
# want a panicked click during install to reboot mid-pacstrap.
|
||||
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
|
||||
if parse_install_progress(log)["status"] != "done":
|
||||
log, code = _read_install_log()
|
||||
if parse_install_progress(log, code)["status"] != "done":
|
||||
return redirect(url_for("install_log_view"))
|
||||
# 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.
|
||||
|
|
|
|||
|
|
@ -1,55 +1,89 @@
|
|||
import json
|
||||
import re
|
||||
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():
|
||||
"""Return the parent disk name of the live-ISO boot media (e.g. "sdb"), or None.
|
||||
|
||||
On a normal box `/run/archiso/bootmnt` does not exist and we return None,
|
||||
leaving the device list untouched. On bare metal booted from USB this is
|
||||
the stick we booted from — we want to filter it out so the user can't
|
||||
accidentally pick it as the install target.
|
||||
On bare metal booted from USB this is the stick we booted from — we want
|
||||
to filter it out so the user can't accidentally pick it as the install
|
||||
target. Primary source is the mount behind /run/archiso/bootmnt; when
|
||||
that's gone (copytoram unmounts it) fall back to the UUID/label archiso
|
||||
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:
|
||||
result = subprocess.run(
|
||||
["findmnt", "-no", "SOURCE", "/run/archiso/bootmnt"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
partition = _boot_device_from_cmdline(Path("/proc/cmdline").read_text())
|
||||
except OSError:
|
||||
partition = None
|
||||
if partition is None:
|
||||
return None
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
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
|
||||
# A stick written with dd has the ISO filesystem on the whole disk, where
|
||||
# PKNAME is empty — the device itself is then the disk.
|
||||
return _parent_disk(partition) or Path(partition).name
|
||||
|
||||
|
||||
def _smart_status(device):
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["smartctl", "-H", device],
|
||||
capture_output=True,
|
||||
)
|
||||
output = result.stdout.decode()
|
||||
if "PASSED" in output:
|
||||
return "passed"
|
||||
elif "FAILED" in output:
|
||||
return "failed"
|
||||
result = _run(["smartctl", "-H", device])
|
||||
if result is None:
|
||||
return "unknown"
|
||||
except Exception as e:
|
||||
print(f"Error checking SMART status for {device}: {e}")
|
||||
if "PASSED" in result.stdout:
|
||||
return "passed"
|
||||
if "FAILED" in result.stdout:
|
||||
return "failed"
|
||||
return "unknown"
|
||||
|
||||
|
||||
|
|
@ -60,40 +94,33 @@ _HEALTH_LABEL = {
|
|||
"unknown": "Status unknown",
|
||||
}
|
||||
|
||||
_TYPE_SCORE = {"NVMe": 15, "SSD": 10, "eMMC": 8, "HDD": 5, "USB": 0}
|
||||
|
||||
|
||||
def get_drive_health(device):
|
||||
return _HEALTH_SCORE[_smart_status(device)]
|
||||
|
||||
|
||||
def get_drive_type_score(device):
|
||||
name = device.lower()
|
||||
if "nvme" in name:
|
||||
return 15
|
||||
if "ssd" in name:
|
||||
return 10
|
||||
return 5
|
||||
def classify_drive(name, tran="", rota=None, removable=False):
|
||||
"""Human label for a disk: NVMe, SSD, eMMC, HDD or USB.
|
||||
|
||||
|
||||
def get_drive_type_label(device):
|
||||
name = device.lower()
|
||||
if "nvme" in name:
|
||||
Uses lsblk's transport and rotational flags — the kernel name alone
|
||||
can't tell an SSD from a spinning disk (both are sdX).
|
||||
"""
|
||||
tran = (tran or "").lower()
|
||||
if tran == "usb" or (removable and not name.startswith("mmcblk")):
|
||||
return "USB"
|
||||
if tran == "nvme" or name.startswith("nvme"):
|
||||
return "NVMe"
|
||||
if "ssd" in name:
|
||||
if tran == "mmc" or name.startswith("mmcblk"):
|
||||
return "eMMC"
|
||||
if rota is False:
|
||||
return "SSD"
|
||||
return "HDD"
|
||||
|
||||
|
||||
def parse_size_gb(size_str):
|
||||
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_drive_type_score(type_label):
|
||||
return _TYPE_SCORE.get(type_label, 5)
|
||||
|
||||
|
||||
def get_size_score(size_gb):
|
||||
|
|
@ -106,61 +133,98 @@ def get_size_score(size_gb):
|
|||
return 10
|
||||
|
||||
|
||||
def score_device(device, size_gb):
|
||||
return get_drive_type_score(device) + get_drive_health(device) + get_size_score(size_gb)
|
||||
def format_size(size_bytes):
|
||||
gb = size_bytes / 1024**3
|
||||
if gb >= 1024:
|
||||
return f"{gb / 1024:.1f} TB".replace(".0 ", " ")
|
||||
if gb >= 10:
|
||||
return f"{gb:.0f} GB"
|
||||
return f"{gb:.1f} GB"
|
||||
|
||||
|
||||
def parse_lsblk_output(output, boot_disk=None):
|
||||
"""Parse `lsblk -dn -o NAME,SIZE,TYPE` output into scored device dicts.
|
||||
def _flag(value):
|
||||
# util-linux ≥2.37 emits JSON booleans; older builds emit "0"/"1".
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip() in ("1", "true")
|
||||
|
||||
Keeps only TYPE=disk so the live ISO's own squashfs (loop) and the boot
|
||||
CD-ROM (rom) don't show up as install targets. If `boot_disk` is given,
|
||||
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
|
||||
valid-looking install target.
|
||||
|
||||
def parse_lsblk_json(output, boot_disk=None, uefi=True):
|
||||
"""Parse `lsblk -J -d -b -o NAME,SIZE,TYPE,RO,RM,TRAN,ROTA` into device dicts.
|
||||
|
||||
Keeps only real, writable, non-empty TYPE=disk devices (drops the live
|
||||
ISO's loop/rom, zram, eMMC boot areas, empty card-reader slots) and the
|
||||
USB stick we booted from. Disks too small to install on stay in the list
|
||||
with `usable=False` and a reason so the user sees why they're greyed out.
|
||||
Sorted best-first; unusable disks last.
|
||||
"""
|
||||
try:
|
||||
entries = json.loads(output or "{}").get("blockdevices", [])
|
||||
except json.JSONDecodeError:
|
||||
return []
|
||||
devices = []
|
||||
for line in output.strip().split("\n"):
|
||||
if not line:
|
||||
for e in entries:
|
||||
name = e.get("name") or ""
|
||||
if e.get("type") != "disk" or not name:
|
||||
continue
|
||||
parts = line.split()
|
||||
if len(parts) < 3:
|
||||
if _PSEUDO_DISK_RE.match(name) or (boot_disk and name == boot_disk):
|
||||
continue
|
||||
name, size, dev_type = parts[0], parts[1], parts[2]
|
||||
if dev_type != "disk":
|
||||
continue
|
||||
if boot_disk and name == boot_disk:
|
||||
try:
|
||||
size_bytes = int(e.get("size") or 0)
|
||||
except (TypeError, ValueError):
|
||||
size_bytes = 0
|
||||
if size_bytes <= 0 or _flag(e.get("ro", False)):
|
||||
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}"
|
||||
size_gb = parse_size_gb(size)
|
||||
status = _smart_status(device)
|
||||
score = get_drive_type_score(device) + _HEALTH_SCORE[status] + get_size_score(size_gb)
|
||||
usable = size_gb >= MIN_DISK_GB
|
||||
status = _smart_status(device) if usable else "unknown"
|
||||
warning = ""
|
||||
if not uefi and size_gb > MBR_LIMIT_GB:
|
||||
warning = (
|
||||
"This computer starts in legacy (BIOS) mode, which can only use "
|
||||
"the first 2 TB of this disk. Switch the firmware to UEFI to use all of it."
|
||||
)
|
||||
elif type_label == "USB":
|
||||
warning = (
|
||||
"This is a USB drive — it works, but an internal disk is faster and more reliable."
|
||||
)
|
||||
devices.append(
|
||||
{
|
||||
"name": device,
|
||||
"size": size,
|
||||
"type_label": get_drive_type_label(device),
|
||||
"size": format_size(size_bytes),
|
||||
"size_gb": size_gb,
|
||||
"type_label": type_label,
|
||||
"health_label": _HEALTH_LABEL[status],
|
||||
"score": score,
|
||||
"score": get_drive_type_score(type_label)
|
||||
+ _HEALTH_SCORE[status]
|
||||
+ get_size_score(size_gb),
|
||||
"usable": usable,
|
||||
"reason": "" if usable else f"Too small — Furtka needs at least {MIN_DISK_GB} GB",
|
||||
"warning": warning,
|
||||
}
|
||||
)
|
||||
devices.sort(key=lambda d: d["score"], reverse=True)
|
||||
devices.sort(key=lambda d: (d["usable"], d["score"]), reverse=True)
|
||||
return devices
|
||||
|
||||
|
||||
def list_scored_devices():
|
||||
"""Return [{name, size, score}, ...] for all physical disks, highest score first."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["lsblk", "-dn", "-o", "NAME,SIZE,TYPE"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error listing devices: {e}")
|
||||
"""Return [{name, size, score, usable, ...}, ...] for all physical disks, best first."""
|
||||
result = _run(["lsblk", "-J", "-d", "-b", "-o", "NAME,SIZE,TYPE,RO,RM,TRAN,ROTA"])
|
||||
if result is None or result.returncode != 0:
|
||||
print(f"Error listing devices: {result.stderr if result else 'lsblk unavailable'}")
|
||||
return []
|
||||
return parse_lsblk_output(result.stdout, boot_disk=_boot_disk_name())
|
||||
return parse_lsblk_json(
|
||||
result.stdout,
|
||||
boot_disk=_boot_disk_name(),
|
||||
uefi=Path("/sys/firmware/efi").exists(),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -168,11 +232,11 @@ def main():
|
|||
if not devices:
|
||||
print("No storage devices found.")
|
||||
return
|
||||
print(f"\n{'Device':<20} {'Size':<10} {'Score'}")
|
||||
print("-" * 40)
|
||||
print(f"\n{'Device':<20} {'Size':<10} {'Type':<6} {'Score'}")
|
||||
print("-" * 46)
|
||||
for d in devices:
|
||||
print(f"{d['name']:<20} {d['size']:<10} {d['score']}")
|
||||
print(f"\nBest drive for boot: {devices[0]['name']} (score {devices[0]['score']})")
|
||||
note = "" if d["usable"] else f" ({d['reason']})"
|
||||
print(f"{d['name']:<20} {d['size']:<10} {d['type_label']:<6} {d['score']}{note}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -190,9 +190,17 @@ select:focus {
|
|||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
flex-wrap: wrap;
|
||||
cursor: pointer;
|
||||
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 input[type="radio"] {
|
||||
accent-color: var(--accent);
|
||||
|
|
|
|||
|
|
@ -18,10 +18,23 @@
|
|||
</h1>
|
||||
<p class="lede" id="install-lede">
|
||||
{% if progress.status == "done" %}Installation finished. Two more steps and Furtka is running from its own disk.
|
||||
{% elif progress.status == "error" %}Something went wrong. Open the details below and share them so we can help.
|
||||
{% else %}This takes a few minutes. Don't close this page or power off the machine.{% endif %}
|
||||
{% elif progress.status == "error" %}{{ progress.phase }}.
|
||||
{% else %}This takes 5–20 minutes, depending on your internet speed. Don't power off the machine — it's fine to close this page and come back.{% endif %}
|
||||
</p>
|
||||
|
||||
{% 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" %}
|
||||
{# 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
|
||||
|
|
@ -43,7 +56,7 @@
|
|||
<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>
|
||||
<p class="progress-phase"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
|
||||
<p class="progress-phase" aria-live="polite"><span id="progress-phase">{{ progress.phase }}</span> · <span id="progress-percent">{{ progress.percent }}</span>%</p>
|
||||
|
||||
<details class="log-details">
|
||||
<summary>Show details</summary>
|
||||
|
|
@ -59,17 +72,7 @@
|
|||
var phaseEl = document.getElementById('progress-phase');
|
||||
var percentEl = document.getElementById('progress-percent');
|
||||
var logEl = document.getElementById('install-log');
|
||||
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.',
|
||||
};
|
||||
var failures = 0;
|
||||
|
||||
function tick() {
|
||||
fetch('{{ url_for("install_log_json") }}', { cache: 'no-store' })
|
||||
|
|
@ -77,6 +80,7 @@
|
|||
.then(function (data) {
|
||||
var p = data.progress;
|
||||
bar.style.width = p.percent + '%';
|
||||
bar.parentNode.setAttribute('aria-valuenow', p.percent);
|
||||
phaseEl.textContent = p.phase;
|
||||
percentEl.textContent = p.percent;
|
||||
if (logEl.textContent !== data.log) {
|
||||
|
|
@ -84,21 +88,23 @@
|
|||
logEl.textContent = data.log || '(waiting for install to start)';
|
||||
if (atBottom) logEl.scrollTop = logEl.scrollHeight;
|
||||
}
|
||||
if (p.status === 'done') {
|
||||
// Reload so the server-rendered Done state (with the
|
||||
// Reboot button) replaces the running-state markup.
|
||||
if (p.status === 'done' || p.status === 'error') {
|
||||
// Reload so the server-rendered Done / error state (with
|
||||
// its Reboot or Try-again buttons) replaces the
|
||||
// running-state markup.
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
if (p.status === 'error') {
|
||||
bar.classList.add('progress-bar-error');
|
||||
headingEl.textContent = HEADINGS.error;
|
||||
ledeEl.textContent = LEDES.error;
|
||||
return;
|
||||
}
|
||||
failures = 0;
|
||||
setTimeout(tick, 3000);
|
||||
})
|
||||
.catch(function () { setTimeout(tick, 3000); });
|
||||
.catch(function () {
|
||||
failures += 1;
|
||||
if (failures >= 2) {
|
||||
phaseEl.textContent = 'Lost connection to the installer — retrying…';
|
||||
}
|
||||
setTimeout(tick, 3000);
|
||||
});
|
||||
}
|
||||
setTimeout(tick, 3000);
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -5,27 +5,27 @@
|
|||
|
||||
{% block content %}
|
||||
<h1>Confirm install</h1>
|
||||
<p class="lede">Review the settings below. Continuing will <strong>wipe <code>{{ settings.boot_drive }}</code></strong> and install Furtka.</p>
|
||||
<p class="lede">Check the settings below. Continuing will <strong>erase <code>{{ settings.boot_drive }}</code></strong> and install Furtka on it.</p>
|
||||
|
||||
<div class="alert alert-warn">
|
||||
This is destructive. All existing data on the selected boot drive will be lost.
|
||||
<div class="alert alert-warn" role="alert">
|
||||
Everything currently on this disk will be deleted and can't be recovered.
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<table>
|
||||
{% for k, v in settings.items() %}
|
||||
<tr>
|
||||
<td>{{ k.replace('_', ' ') }}</td>
|
||||
<td>{{ v }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr><td>Server name</td><td>{{ settings.hostname }} <span class="hint">(http://{{ settings.hostname }}.local)</span></td></tr>
|
||||
<tr><td>Username</td><td>{{ settings.username }}</td></tr>
|
||||
<tr><td>Password</td><td>{{ settings.password }}</td></tr>
|
||||
<tr><td>Language</td><td>{{ language_label }}</td></tr>
|
||||
<tr><td>Disk</td><td>{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}</td></tr>
|
||||
</table>
|
||||
</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">
|
||||
<button type="submit" class="btn btn-danger">Install — wipe drive and proceed</button>
|
||||
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Start over</a>
|
||||
<button type="submit" class="btn btn-danger">Erase disk and install</button>
|
||||
<a href="{{ url_for('install_step_2') }}" class="btn btn-link">← Back</a>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -10,5 +10,7 @@
|
|||
<p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p>
|
||||
<p>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 class="muted">If that doesn't resolve, your network may not support mDNS — use the IP address shown on the machine's console instead.</p>
|
||||
<p class="muted">If that address doesn't open (some Android phones and older Windows PCs can't look up <code>.local</code> names),
|
||||
use the IP address instead: it's shown on the machine's screen, or look for a device called
|
||||
<strong>{{ hostname }}</strong> in your router's list of connected devices. The address may differ from the one the installer used.</p>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@
|
|||
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Account & hostname</h1>
|
||||
<h1>Name & account</h1>
|
||||
<p class="lede">Set the server name and your administrator account.</p>
|
||||
|
||||
{% if errors %}
|
||||
<div class="alert alert-error">
|
||||
<div class="alert alert-error" role="alert">
|
||||
<strong>Please fix the following:</strong>
|
||||
<ul>
|
||||
{% for e in errors %}<li>{{ e }}</li>{% endfor %}
|
||||
|
|
@ -18,19 +18,19 @@
|
|||
|
||||
<form method="post" action="{{ url_for('install_step_1') }}">
|
||||
<div class="field">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" />
|
||||
<span class="hint">The name your server shows up as on your home network. Lowercase letters, numbers, and dashes only.</span>
|
||||
<label for="hostname">Server name</label>
|
||||
<input type="text" id="hostname" name="hostname" required value="{{ values.hostname }}" autocomplete="off" autocapitalize="off" spellcheck="false" aria-describedby="hostname-hint" />
|
||||
<span class="hint" id="hostname-hint">You'll open your server at <strong>http://<span id="hostname-preview">{{ values.hostname or "furtka" }}</span>.local</strong>. Letters, numbers and dashes only.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="username">Admin username</label>
|
||||
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" />
|
||||
<span class="hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
||||
<input type="text" id="username" name="username" required value="{{ values.username }}" autocomplete="username" autocapitalize="off" spellcheck="false" aria-describedby="username-hint" />
|
||||
<span class="hint" id="username-hint">Your login name on the server. You'll use this to sign in after installation.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" required autocomplete="new-password" />
|
||||
<span class="hint">At least 8 characters.</span>
|
||||
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password" aria-describedby="password-hint" />
|
||||
<span class="hint" id="password-hint">At least 8 characters. This one password is used for the web login and for the server itself (SSH / console), so pick a good one.</span>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="password2">Confirm password</label>
|
||||
|
|
@ -38,15 +38,25 @@
|
|||
</div>
|
||||
<div class="field">
|
||||
<label for="language">Language</label>
|
||||
<select id="language" name="language">
|
||||
<select id="language" name="language" aria-describedby="language-hint">
|
||||
{% for code, lang in languages.items() %}
|
||||
<option value="{{ code }}" {% if code == values.language %}selected{% endif %}>{{ lang.label }}</option>
|
||||
{% endfor %}
|
||||
</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 class="actions">
|
||||
<button type="submit" class="btn btn-primary">Next — pick boot drive</button>
|
||||
<button type="submit" class="btn btn-primary">Next — pick a disk</button>
|
||||
</div>
|
||||
</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 %}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,53 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Step 2 — Boot drive · Furtka Installer{% endblock %}
|
||||
{% block title %}Step 2 — Disk · Furtka Installer{% endblock %}
|
||||
{% block step_indicator %}<span class="step-indicator">Step 2 of 3</span>{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>Boot drive</h1>
|
||||
<p class="lede">Pick the disk Furtka will install onto. We pre-select the drive that looks fastest and healthiest.</p>
|
||||
<h1>Pick a disk</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>
|
||||
|
||||
{% if not drives %}
|
||||
<div class="alert alert-error">
|
||||
No physical disks detected. The live ISO can't see any install targets — check your BIOS / SATA controller.
|
||||
{% set usable = drives | selectattr("usable") | list %}
|
||||
{% set pick = selected if (selected and usable | selectattr("name", "equalto", selected) | list) else (usable[0].name if usable else "") %}
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{{ url_for('install_step_2') }}">
|
||||
<div class="drive-list">
|
||||
{% for d in drives %}
|
||||
<label class="drive">
|
||||
<label class="drive{% if not d.usable %} drive-disabled{% endif %}">
|
||||
<input type="radio" name="boot_drive" value="{{ d.name }}"
|
||||
{% if (selected and d.name == selected) or (not selected and loop.first) %}checked{% endif %} />
|
||||
{% if not d.usable %}disabled{% elif d.name == pick %}checked{% endif %} />
|
||||
<span class="name">{{ d.name }}</span>
|
||||
{% if not selected and loop.first %}
|
||||
{% if not selected and d.usable and loop.first %}
|
||||
<span class="badge-recommended">Recommended</span>
|
||||
{% endif %}
|
||||
<span class="meta">
|
||||
<span class="chip">{{ d.size }}</span>
|
||||
<span class="chip">{{ d.type_label }}</span>
|
||||
<span class="chip">{{ d.health_label }}</span>
|
||||
{% if d.usable %}<span class="chip">{{ d.health_label }}</span>{% endif %}
|
||||
</span>
|
||||
{% if d.reason %}<span class="drive-note">{{ d.reason }}</span>{% endif %}
|
||||
{% if d.warning %}<span class="drive-note">{{ d.warning }}</span>{% endif %}
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="submit" class="btn btn-primary" {% if not drives %}disabled{% endif %}>Next — review</button>
|
||||
<button type="submit" class="btn btn-primary" {% if not usable %}disabled{% endif %}>Next — review</button>
|
||||
<a href="{{ url_for('install_step_1') }}" class="btn btn-link">← Back</a>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
#!/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.
|
||||
|
||||
python3 website/tools/logo-assets.py
|
||||
|
|
@ -10,6 +11,7 @@ the output — the site build itself needs nothing beyond Hugo.
|
|||
Mark M: lowercase f knocked out of a gate, 64-unit grid (even-odd fill).
|
||||
Mark R: window, wheel and F, 104x114 grid, stroke 8 (from Robert's sketch).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
|
|
@ -119,7 +121,9 @@ def raster(svgtxt, w, h):
|
|||
|
||||
def mark_png(k, tier, height, color=None):
|
||||
_, _, w, h = MARKS[k]["vb"]
|
||||
return raster(svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height)
|
||||
return raster(
|
||||
svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- wordmark
|
||||
|
|
@ -141,7 +145,9 @@ def wordmark(text, size, x0=0.0, baseline=0.0, tracking=-0.028):
|
|||
g.draw(pen)
|
||||
d = pen.getCommands()
|
||||
if d:
|
||||
parts.append(f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>')
|
||||
parts.append(
|
||||
f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>'
|
||||
)
|
||||
x += g.width * s + tracking * size
|
||||
return "".join(parts), x - x0 - tracking * size
|
||||
|
||||
|
|
@ -241,21 +247,24 @@ def vote_card(path):
|
|||
m = mark_png(k, "flat", 330)
|
||||
im.alpha_composite(m, (cx - m.width // 2, 110 - (m.height - 330) // 2))
|
||||
size = 46
|
||||
d = ImageDraw.Draw(im)
|
||||
for text, cx, col in (("M", W // 4, "#6b6b6f"), ("R", 3 * W // 4, "#8a8a90")):
|
||||
paths, tw = wordmark(text, size)
|
||||
# letters via the same outlines: rasterise a tiny SVG for crisp text
|
||||
svgtxt = (f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">'
|
||||
f'<g fill="{col}">{paths}</g></svg>')
|
||||
svgtxt = (
|
||||
f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">'
|
||||
f'<g fill="{col}">{paths}</g></svg>'
|
||||
)
|
||||
t = raster(svgtxt, round(tw), round(size * 1.3))
|
||||
im.alpha_composite(t, (cx - t.width // 2, 500))
|
||||
# url line, centred on the seam
|
||||
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)
|
||||
pad = 18
|
||||
box = Image.new("RGBA", (t.width + 2 * pad, t.height + 2 * pad), "#f7f6f3")
|
||||
ImageDraw.Draw(box).rounded_rectangle([0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc")
|
||||
ImageDraw.Draw(box).rounded_rectangle(
|
||||
[0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc"
|
||||
)
|
||||
box.alpha_composite(t, (pad, pad))
|
||||
im.alpha_composite(box, (W // 2 - box.width // 2, 40))
|
||||
im.convert("RGB").save(path, optimize=True)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue