From 7151d411c7e288e78c6622ff7394968fc24f581f Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Mon, 28 Sep 2026 13:21:55 +0200 Subject: [PATCH] feat(installer): friendlier wizard, retry after a failed install, no boot stall on multi-NIC boards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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://.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 Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73 --- .../usr/local/bin/furtka-update-issue | 2 + tests/test_app.py | 59 +++++++++++++ webinstaller/app.py | 83 +++++++++++++++++-- webinstaller/templates/install/log.html | 54 ++++++------ webinstaller/templates/install/overview.html | 21 +++-- webinstaller/templates/install/rebooting.html | 4 +- webinstaller/templates/install/step1.html | 32 ++++--- webinstaller/templates/install/step2.html | 10 +-- 8 files changed, 207 insertions(+), 58 deletions(-) diff --git a/iso/overlay/airootfs/usr/local/bin/furtka-update-issue b/iso/overlay/airootfs/usr/local/bin/furtka-update-issue index f63565f..e56b1c5 100755 --- a/iso/overlay/airootfs/usr/local/bin/furtka-update-issue +++ b/iso/overlay/airootfs/usr/local/bin/furtka-update-issue @@ -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 diff --git a/tests/test_app.py b/tests/test_app.py index cc0172a..425c414 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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 diff --git a/webinstaller/app.py b/webinstaller/app.py index 6658f3f..a3b9ed9 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -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//, # 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 + diff --git a/webinstaller/templates/install/log.html b/webinstaller/templates/install/log.html index 2d1953b..c1f7097 100644 --- a/webinstaller/templates/install/log.html +++ b/webinstaller/templates/install/log.html @@ -18,10 +18,23 @@

{% 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 %}

+{% if progress.status == "error" %} +

Your settings are kept, so you can simply try again. If it fails the same way twice, +open Show details below — the last lines of the log say what went wrong.

+
+ +
+{% 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 @@
-

{{ progress.phase }} · {{ progress.percent }}%

+

{{ progress.phase }} · {{ progress.percent }}%

Show details @@ -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); })(); diff --git a/webinstaller/templates/install/overview.html b/webinstaller/templates/install/overview.html index 95ea3c0..d6de0c0 100644 --- a/webinstaller/templates/install/overview.html +++ b/webinstaller/templates/install/overview.html @@ -5,28 +5,27 @@ {% block content %}

Confirm install

-

Review the settings below. Continuing will wipe {{ settings.boot_drive }} and install Furtka.

+

Check the settings below. Continuing will erase {{ settings.boot_drive }} and install Furtka on it.

-
- This is destructive. All existing data on the selected boot drive will be lost. +
- {% for k, v in settings.items() %} - - - - - {% endfor %} + + + + +
{{ k.replace('_', ' ') }}{{ v }}
Server name{{ settings.hostname }} (http://{{ settings.hostname }}.local)
Username{{ settings.username }}
Password{{ settings.password }}
Language{{ language_label }}
Disk{{ settings.boot_drive }}{% if drive %} — {{ drive.size }} {{ drive.type_label }}{% endif %}
- - ← Start over + + ← Back
{% endblock %} diff --git a/webinstaller/templates/install/rebooting.html b/webinstaller/templates/install/rebooting.html index 4a3b6fb..116aa4c 100644 --- a/webinstaller/templates/install/rebooting.html +++ b/webinstaller/templates/install/rebooting.html @@ -10,5 +10,7 @@

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 F11, F12, or Esc — it flashes briefly on screen) and pick the internal disk, or change the boot order in BIOS settings.

When the machine comes back up (~1 minute), open Furtka in your browser:

http://{{ hostname }}.local

-

If that doesn't resolve, your network may not support mDNS — use the IP address shown on the machine's console instead.

+

If that address doesn't open (some Android phones and older Windows PCs can't look up .local names), +use the IP address instead: it's shown on the machine's screen, or look for a device called +{{ hostname }} in your router's list of connected devices. The address may differ from the one the installer used.

{% endblock %} diff --git a/webinstaller/templates/install/step1.html b/webinstaller/templates/install/step1.html index 77ea833..d911297 100644 --- a/webinstaller/templates/install/step1.html +++ b/webinstaller/templates/install/step1.html @@ -4,11 +4,11 @@ {% block step_indicator %}Step 1 of 3{% endblock %} {% block content %} -

Account & hostname

+

Name & account

Set the server name and your administrator account.

{% if errors %} -
+