From bfa7ee8cba9f4fe3e98723ab0b070df77cadd3e6 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Mon, 28 Sep 2026 14:14:59 +0200 Subject: [PATCH 1/4] build(iso): pin archinstall 4.4-1 from the Arch archive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The webinstaller imports archinstall internals and sed-patches one of its files, so every ISO build silently picked up whatever archinstall Arch shipped that day — any upstream release could break all installs with no change on our side. build.sh now downloads the validated build from archive.archlinux.org, checks its sha256, and serves it from a local [furtka-pinned] repo ahead of [core] in the build's pacman.conf. The build fails with a clear message once Arch's Python moves past the one the pinned package targets. Verified in an archlinux:latest container: pacman resolves 'furtka-pinned archinstall 4.4-1'. Bump steps in iso/README.md. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73 --- iso/README.md | 11 +++++++++++ iso/build.sh | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/iso/README.md b/iso/README.md index e5cbb3d..087822e 100644 --- a/iso/README.md +++ b/iso/README.md @@ -34,6 +34,17 @@ The systemd service runs `flask --app app run --host 0.0.0.0 --port 5000` under mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live ISO's hostname is `proksi`, and as soon as `systemd-networkd-wait-online` fires the installer is reachable at `http://proksi.local:5000`. The raw IP still shows on the console for fallback — some Windows clients need the Bonjour service for `.local` to resolve at all. +### archinstall is pinned + +The webinstaller imports archinstall internals and `furtka-webinstaller.service` sed-patches one of its files, so `build.sh` does not take whatever archinstall the Arch repos currently ship. It downloads the version in `ARCHINSTALL_PIN` from `archive.archlinux.org`, checks it against `ARCHINSTALL_SHA256`, and serves it from a local `[furtka-pinned]` repo listed before `[core]` in the build's `pacman.conf` (pacman takes a package from the first repo that has it). Only the build uses that repo; the live system's own `pacman.conf` is untouched. + +The build fails with "pinned archinstall … targets Python X, Arch now ships Y" once Arch moves to a new Python — the pinned package would install into a dead `site-packages`. To bump: + +1. Pick the new version from . +2. `curl -LO` the `.pkg.tar.zst`, `sha256sum` it, and update `ARCHINSTALL_PIN` + `ARCHINSTALL_SHA256` at the top of `build.sh`. +3. Check that `webinstaller/app.py:build_disk_config` still imports and that the `ExecStartPre` sed in `furtka-webinstaller.service` still matches (`journalctl -u furtka-webinstaller` on the live ISO). +4. Full VM install (UEFI and BIOS) before merging. + `build.sh` also deletes releng's `getty@tty1.service.d/autologin.conf`. With root auto-logged-in, tty1 belongs to a shell and `agetty --reload` can't redraw the banner, so the IP line was a snapshot of whatever DHCP had done by the time getty started. Without autologin tty1 sits at the login prompt showing the live banner; for a shell type `root` + Enter (releng's root has no password) — the banner says so. ## Test flow diff --git a/iso/build.sh b/iso/build.sh index 1587633..499ddab 100755 --- a/iso/build.sh +++ b/iso/build.sh @@ -27,7 +27,11 @@ fi # ---- inside the container from here on ---- echo "==> Syncing pacman, installing archiso" -pacman -Syu --noconfirm --needed archiso +pacman -Syu --noconfirm --needed archiso curl zstd + +# archinstall build the webinstaller is validated against (see pin step below). +ARCHINSTALL_PIN="4.4-1" +ARCHINSTALL_SHA256="6cb5da75c108ef2f5d25a7a20baf6c52cc927d5e06890dd10182e6eb4be815cb" PROFILE_SRC="/usr/share/archiso/configs/releng" PROFILE_WORK="/tmp/furtka-profile" @@ -43,6 +47,37 @@ cat "$SCRIPT_DIR/overlay/packages.extra" >> "$PROFILE_WORK/packages.x86_64" cat "$SCRIPT_DIR/overlay/profiledef.sh" >> "$PROFILE_WORK/profiledef.sh" +echo "==> Pinning archinstall ${ARCHINSTALL_PIN}" +# The webinstaller imports archinstall internals (build_disk_config) and +# sed-patches one of its files at service start, so a silent archinstall +# upgrade in the Arch repos can break every install with no change on our +# side. Serve the validated build from a local repo listed *before* [core]: +# pacman takes a package from the first repo that has it, whatever the +# version elsewhere. Bump deliberately: new version + sha256 below, rebuild, +# run the smoke VM (see iso/README.md). +PINNED_REPO="/tmp/furtka-pinned-repo" +rm -rf "$PINNED_REPO" && mkdir -p "$PINNED_REPO" +ARCHINSTALL_PKG="archinstall-${ARCHINSTALL_PIN}-any.pkg.tar.zst" +curl -fsSL --retry 3 -o "$PINNED_REPO/$ARCHINSTALL_PKG" \ + "https://archive.archlinux.org/packages/a/archinstall/$ARCHINSTALL_PKG" +echo "${ARCHINSTALL_SHA256} $PINNED_REPO/$ARCHINSTALL_PKG" | sha256sum -c - +# archinstall is an "any" package installed under /usr/lib/python3.X; after +# Arch moves to a new Python it would install into a dead directory. Fail +# the build with a clear message instead of shipping a broken installer. +pkg_py=$(tar --zstd -tf "$PINNED_REPO/$ARCHINSTALL_PKG" \ + | sed -n 's|^usr/lib/python\(3\.[0-9]*\)/.*|\1|p' | sort -u) +repo_py=$(pacman -Si python | awk -F': ' '/^Version/ {split($2, v, "."); print v[1] "." v[2]}') +if [ "$pkg_py" != "$repo_py" ]; then + echo "ERROR: pinned archinstall ${ARCHINSTALL_PIN} targets Python $pkg_py, Arch now ships $repo_py." >&2 + echo " Bump ARCHINSTALL_PIN/ARCHINSTALL_SHA256 in iso/build.sh (see iso/README.md)." >&2 + exit 1 +fi +repo-add -q "$PINNED_REPO/furtka-pinned.db.tar.gz" "$PINNED_REPO/$ARCHINSTALL_PKG" +# The sha256 above already pins the exact bytes. +sed -i "0,/^\[core\]/s||[furtka-pinned]\nSigLevel = Optional TrustAll\nServer = file://$PINNED_REPO\n\n[core]|" \ + "$PROFILE_WORK/pacman.conf" +grep -q '^\[furtka-pinned\]' "$PROFILE_WORK/pacman.conf" + cp -a "$SCRIPT_DIR/overlay/airootfs/." "$PROFILE_WORK/airootfs/" # releng auto-logs root in on tty1. That turns our /etc/issue banner into a From 455150f97f9e6025e82236e5a389b4c8bf61ba73 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Mon, 28 Sep 2026 14:21:14 +0200 Subject: [PATCH 2/4] fix(installer): use Limine on 32-bit UEFI firmware Atom Bay Trail / Cherry Trail mini-PCs and tablets have a 64-bit CPU behind 32-bit UEFI. We picked systemd-boot there (Arch ships no ia32 build), and archinstall 4.4's GRUB path hardcodes --target=x86_64-efi, so the installed system could not boot. archinstall's Limine path copies both BOOTIA32.EFI and BOOTX64.EFI and chooses the entry by /sys/firmware/efi/fw_platform_size; the default layout's FAT /boot satisfies its layout check. 64-bit UEFI and BIOS keep systemd-boot / GRUB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73 --- tests/test_app.py | 13 +++++++++++++ webinstaller/app.py | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/tests/test_app.py b/tests/test_app.py index 425c414..6ab75b4 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -390,3 +390,16 @@ def test_error_page_offers_retry(monkeypatch, tmp_path): 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 + + +def test_detect_bootloader_by_firmware(tmp_path): + import app as app_module + + efi = tmp_path / "efi" + assert app_module._detect_bootloader(efi) == "Grub" # BIOS / legacy + efi.mkdir() + assert app_module._detect_bootloader(efi) == "Systemd-boot" # no bitness file + (efi / "fw_platform_size").write_text("64\n") + assert app_module._detect_bootloader(efi) == "Systemd-boot" + (efi / "fw_platform_size").write_text("32\n") + assert app_module._detect_bootloader(efi) == "Limine" diff --git a/webinstaller/app.py b/webinstaller/app.py index a3b9ed9..160d4c7 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -515,10 +515,20 @@ def _post_install_commands(hostname, admin_username, admin_password): ] -def _detect_bootloader(): +def _detect_bootloader(efi_dir=Path("/sys/firmware/efi")): # systemd-boot is UEFI-only; on BIOS/legacy it trips HardwareIncompatibilityError # inside archinstall. /sys/firmware/efi exists iff we were booted via UEFI. - return "Systemd-boot" if Path("/sys/firmware/efi").exists() else "Grub" + if not efi_dir.exists(): + return "Grub" + # 64-bit CPU behind 32-bit UEFI (Atom Bay Trail / Cherry Trail mini-PCs + # and tablets): Arch ships no ia32 systemd-boot, and archinstall's GRUB + # path hardcodes --target=x86_64-efi. Its Limine path installs both + # BOOTIA32.EFI and BOOTX64.EFI and picks the entry by firmware bitness. + try: + bitness = (efi_dir / "fw_platform_size").read_text().strip() + except OSError: + bitness = "64" + return "Limine" if bitness == "32" else "Systemd-boot" def build_archinstall_config(s): From 2468cb3ded61b58ac2273d43661af6706ed594ce Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Mon, 28 Sep 2026 14:21:14 +0200 Subject: [PATCH 3/4] fix(https): redirect only the certificate's hostnames, with 302 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The force-HTTPS snippet was 'redir https://{host}{uri} permanent': - every Host was redirected, including a bare IP that the tls-internal block has no certificate for — devices reaching the box by IP (no mDNS on Android / older Windows, the console IP fallback) couldn't load the UI at all - 301 is cached by browsers, so switching HTTPS off kept bouncing them to a dead :443 Now a host matcher limits the redirect to .local and and it's a 302. Self-update rewrites the old snippet if it is still the untouched legacy content. Browsers that already cached the old 301 keep it until their cache is cleared. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73 --- furtka/https.py | 24 +++++++++++++++++++++--- furtka/updater.py | 15 +++++++++++++++ tests/test_https.py | 16 +++++++++++----- tests/test_updater.py | 28 ++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 8 deletions(-) diff --git a/furtka/https.py b/furtka/https.py index ce8e582..ed08759 100644 --- a/furtka/https.py +++ b/furtka/https.py @@ -36,7 +36,11 @@ from pathlib import Path CA_CERT_PATH = Path("/var/lib/caddy/pki/authorities/local/root.crt") SNIPPET_DIR = Path("/etc/caddy/furtka.d") REDIRECT_SNIPPET = SNIPPET_DIR / "redirect.caddyfile" -REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n" +# Pre-26.21 redirect: permanent (browsers cache a 301 indefinitely, so +# turning HTTPS off left them bouncing to a dead :443) and for every Host — +# including a bare IP, which has no certificate, so devices that reach the +# box by IP (no mDNS on Android / older Windows) couldn't load the UI at all. +LEGACY_REDIRECT_CONTENT = "redir https://{host}{uri} permanent\n" HTTPS_SNIPPET_DIR = Path("/etc/caddy/furtka-https.d") HTTPS_SNIPPET = HTTPS_SNIPPET_DIR / "https.caddyfile" HOSTNAME_FILE = Path("/etc/hostname") @@ -62,6 +66,19 @@ def _read_hostname(hostname_file: Path = HOSTNAME_FILE) -> str: return value or "furtka" +def _redirect_snippet_content(hostname: str) -> str: + """HTTP→HTTPS redirect for the names the HTTPS block has a cert for. + + Only those hosts are redirected: an IP or any other name keeps plain + HTTP instead of landing on a TLS handshake that can't succeed. 302 so + switching HTTPS off takes effect in browsers immediately. + """ + return ( + f"@furtka_https_host host {hostname}.local {hostname}\n" + "redir @furtka_https_host https://{host}{uri} 302\n" + ) + + def _https_snippet_content(hostname: str) -> str: """Caddy site block the HTTPS toggle installs at opt-in. @@ -151,8 +168,9 @@ def set_force_https( previous_https = https_snippet.read_text() if had_https else None if enabled: - snippet.write_text(REDIRECT_CONTENT) - https_snippet.write_text(_https_snippet_content(_read_hostname(hostname_file))) + hostname = _read_hostname(hostname_file) + snippet.write_text(_redirect_snippet_content(hostname)) + https_snippet.write_text(_https_snippet_content(hostname)) else: if had_redirect: snippet.unlink() diff --git a/furtka/updater.py b/furtka/updater.py index 39455fd..b3aceea 100644 --- a/furtka/updater.py +++ b/furtka/updater.py @@ -192,6 +192,20 @@ def _maybe_migrate_preserve_https() -> None: ) +def _maybe_migrate_redirect_snippet() -> None: + """26.21 migration: swap the old catch-all 301 redirect for the + hostname-scoped 302 one, so boxes reached by IP can load the UI again + and turning HTTPS off stops sticking in browser caches.""" + from furtka import https + + redirect_snippet = _CADDY_SNIPPET_DIR / "redirect.caddyfile" + if not redirect_snippet.is_file(): + return + if redirect_snippet.read_text() != https.LEGACY_REDIRECT_CONTENT: + return + redirect_snippet.write_text(https._redirect_snippet_content(_current_hostname())) + + def _refresh_caddyfile(source: Path) -> bool: """Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True if the file changed (so caddy needs more than a bare reload). @@ -215,6 +229,7 @@ def _refresh_caddyfile(source: Path) -> bool: # hostname block is gone, so the redirect would 301 to a dead :443. # Preserve intent by writing the HTTPS listener snippet too. _maybe_migrate_preserve_https() + _maybe_migrate_redirect_snippet() rendered = source.read_text().replace(_CADDYFILE_HOSTNAME_MARKER, _current_hostname()) if _CADDYFILE_LIVE.is_file() and rendered == _CADDYFILE_LIVE.read_text(): return False diff --git a/tests/test_https.py b/tests/test_https.py index 7b423cf..86dc8b9 100644 --- a/tests/test_https.py +++ b/tests/test_https.py @@ -113,7 +113,7 @@ def test_set_force_enable_writes_both_snippets_and_reloads(tmp_path): result = https.set_force_https(True, reload_caddy=fake_reload, **p) assert result is True - assert p["snippet"].read_text() == https.REDIRECT_CONTENT + assert p["snippet"].read_text() == https._redirect_snippet_content("testbox") written = p["https_snippet"].read_text() assert "testbox.local, testbox" in written assert "tls internal" in written @@ -135,7 +135,7 @@ def test_set_force_disable_removes_both_snippets(tmp_path): p = _paths(tmp_path) p["snippet_dir"].mkdir() p["https_snippet_dir"].mkdir() - p["snippet"].write_text(https.REDIRECT_CONTENT) + p["snippet"].write_text(https._redirect_snippet_content("testbox")) p["https_snippet"].write_text("furtka.local { tls internal }\n") result = https.set_force_https(False, reload_caddy=lambda: None, **p) @@ -199,10 +199,16 @@ def test_systemctl_missing_raises_and_rolls_back(tmp_path): assert not p["https_snippet"].exists() -def test_redirect_snippet_content_is_caddy_redir_directive(): - # Lock the exact directive. A regression here silently stops the +def test_redirect_snippet_only_covers_cert_names_and_is_temporary(): + # Lock the exact directives. A regression here silently stops the # redirect from taking effect even though the file-swap looks fine. - assert https.REDIRECT_CONTENT.strip() == "redir https://{host}{uri} permanent" + # Only the names the tls-internal block has a cert for are redirected + # (an IP would hit a failing TLS handshake), and 302 so switching HTTPS + # off isn't stuck in browser caches like the old 301 was. + assert https._redirect_snippet_content("mybox") == ( + "@furtka_https_host host mybox.local mybox\n" + "redir @furtka_https_host https://{host}{uri} 302\n" + ) def test_https_snippet_content_has_tls_internal_and_routes(tmp_path): diff --git a/tests/test_updater.py b/tests/test_updater.py index 13fddd9..bba2d3e 100644 --- a/tests/test_updater.py +++ b/tests/test_updater.py @@ -539,3 +539,31 @@ def test_health_check_survives_read_timeout(updater, monkeypatch): 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 + + +def test_refresh_caddyfile_migrates_legacy_301_redirect(updater, tmp_path): + from furtka import https + + snippet_dir = tmp_path / "etc_caddy" / "furtka.d" + snippet_dir.mkdir(parents=True) + (tmp_path / "etc_caddy" / "furtka-https.d").mkdir() + (tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n") + redirect = snippet_dir / "redirect.caddyfile" + redirect.write_text(https.LEGACY_REDIRECT_CONTENT) + src = tmp_path / "Caddyfile.src" + src.write_text("# caddy\n") + updater._refresh_caddyfile(src) + assert redirect.read_text() == https._redirect_snippet_content("testbox") + + +def test_refresh_caddyfile_leaves_custom_redirect_alone(updater, tmp_path): + snippet_dir = tmp_path / "etc_caddy" / "furtka.d" + snippet_dir.mkdir(parents=True) + (tmp_path / "etc_caddy" / "furtka-https.d").mkdir() + (tmp_path / "etc_caddy" / "furtka-https.d" / "https.caddyfile").write_text("x\n") + redirect = snippet_dir / "redirect.caddyfile" + redirect.write_text("# hand-edited\n") + src = tmp_path / "Caddyfile.src" + src.write_text("# caddy\n") + updater._refresh_caddyfile(src) + assert redirect.read_text() == "# hand-edited\n" From ff5fe6f1c68275de560df41d983df135a2da3216 Mon Sep 17 00:00:00 2001 From: Daniel Maksymilian Syrnicki Date: Mon, 28 Sep 2026 14:24:16 +0200 Subject: [PATCH 4/4] docs(changelog): archinstall pin, 32-bit UEFI, HTTPS redirect Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Wq1hzUp4ZmCKHDuLKCnu73 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb9796d..1055216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,13 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r full. - **Apps page after an API restart** redirects to `/login` instead of showing an empty app list. +- **32-bit UEFI machines** (Atom Bay Trail / Cherry Trail) get Limine + instead of systemd-boot: Arch has no ia32 systemd-boot and archinstall's + GRUB path hardcodes `x86_64-efi`, so these boxes installed but didn't boot. +- **Force-HTTPS redirect** only covers `.local` / `` and + is a 302. The old 301 for every host sent devices that use the IP to a + TLS handshake that can't succeed, and stayed cached after HTTPS was + turned off. Self-update migrates the old snippet. - **Self-update starts newly added timers immediately and links units through `current`.** `_link_new_units()` enabled a timer a release @@ -68,6 +75,9 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r 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. +- ISO builds pin archinstall (4.4-1, sha256-checked from the Arch archive) + instead of taking whatever the repos ship that day; the build fails + clearly when Arch's Python moves on. Bump steps in `iso/README.md`. ## [26.20-alpha] - 2026-08-25