feat(installer): friendlier wizard, retry after a failed install, no boot stall on multi-NIC boards

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-09-28 13:21:55 +02:00
parent 9f9e627678
commit 7151d411c7
8 changed files with 207 additions and 58 deletions

View file

@ -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

View file

@ -331,3 +331,62 @@ def test_step2_rejects_disk_not_offered(monkeypatch):
resp = client.post("/install/step2", data={"boot_drive": "/dev/sda"})
assert resp.status_code == 302
assert app_module.settings["boot_drive"] == "/dev/sda"
def test_validate_step1_lowercases_names():
errors, values = validate_step1(
{
"hostname": "Furtka",
"username": "Daniel",
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "de",
}
)
assert errors == []
assert values["hostname"] == "furtka"
assert values["username"] == "daniel"
def test_validate_step1_rejects_reserved_usernames():
for name in ("root", "caddy", "docker", "nobody"):
errors, _ = validate_step1(
{
"hostname": "furtka",
"username": name,
"password": "topsecretpw",
"password2": "topsecretpw",
"language": "en",
}
)
assert len(errors) == 1 and "reserved" in errors[0], name
def test_post_install_sets_wait_online_any(monkeypatch):
import base64
import app as app_module
cmds = app_module._post_install_commands("h", "u", "pw12345678")
hits = [c for c in cmds if "systemd-networkd-wait-online.service.d" in c]
assert len(hits) == 1
payload = hits[0].split("printf %s ")[1].split(" |")[0]
assert "--any" in base64.b64decode(payload).decode()
def test_wizard_pages_redirect_to_progress_while_installing(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
monkeypatch.setattr(app_module, "_install_proc", _FakeProc(code=None))
client = app_module.app.test_client()
for path in ("/install/step1", "/install/step2", "/install/overview"):
resp = client.get(path)
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/install/log")
def test_error_page_offers_retry(monkeypatch, tmp_path):
app_module = _install_env(monkeypatch, tmp_path)
(tmp_path / "install.log").write_text("Traceback (most recent call last)\nboom\n")
html = app_module.app.test_client().get("/install/log").get_data(as_text=True)
assert "Try again" in html
assert "next-steps" not in html

View file

@ -78,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
@ -148,18 +177,26 @@ def parse_install_progress(log, exit_code=None):
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:
@ -453,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(),
@ -585,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"))
@ -609,6 +666,11 @@ 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()
@ -616,8 +678,11 @@ def install_step_2():
if request.method == "POST":
boot_drive = request.form.get("boot_drive", "").strip()
# Only accept a disk we actually offered — never a free-form path.
if any(d["name"] == boot_drive and d["usable"] for d in drives):
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(
@ -630,8 +695,14 @@ def install_step_2():
@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 +

View file

@ -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);
})();

View file

@ -5,28 +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') }}"
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 %}

View file

@ -10,5 +10,7 @@
<p class="muted">If the installer does come back anyway, your BIOS is set to boot from USB before the disk. Press the one-time boot menu key at startup (often <kbd>F11</kbd>, <kbd>F12</kbd>, or <kbd>Esc</kbd> — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.</p>
<p>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 %}

View file

@ -4,11 +4,11 @@
{% block step_indicator %}<span class="step-indicator">Step 1 of 3</span>{% endblock %}
{% block content %}
<h1>Account &amp; hostname</h1>
<h1>Name &amp; 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 %}

View file

@ -1,21 +1,21 @@
{% 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>
{% 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">{{ error }}</div>
<div class="alert alert-error" role="alert">{{ error }}</div>
{% endif %}
{% if not usable %}
<div class="alert alert-error">
<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 %}