feat: post-install bootstrap — land in Furtka after reboot
Some checks failed
Build ISO / build-iso (push) Successful in 16m47s
CI / lint (push) Failing after 32s
CI / test (push) Successful in 33s
CI / validate-json (push) Successful in 23s
CI / markdown-links (push) Successful in 13s

Installs caddy + avahi + nss-mdns on the target and writes a small
landing page, live status tiles (uptime / docker version / free disk
via furtka-status.timer), and a console welcome banner — all via
archinstall's custom_commands so the payload travels with the
user_configuration.json. After reboot `http://<hostname>.local`
serves a Furtka-branded page on :80 instead of the bare Arch login.

No Authentik / no app store yet — demo shell for the real post-
install work (Robert's area).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-14 19:51:50 +02:00
parent dfdbdd69aa
commit 8ed1d82fd3
4 changed files with 408 additions and 9 deletions

View file

@ -112,7 +112,7 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] **Rebrand GRUB menu**`iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs; default entry marked `(Recommended)`. - [x] **Rebrand GRUB menu**`iso/build.sh` rewrites "Arch Linux install medium" → "Furtka Live Installer" across GRUB, syslinux, and systemd-boot configs; default entry marked `(Recommended)`.
- [x] **Wizard: account form → drive picker → overview → archinstall** — S1 collects hostname/user/password/language with validation, S2 picks boot drive, overview confirms, `/install/run` writes `user_configuration.json` + `user_credentials.json` (0600) and execs `archinstall --silent` against its 4.x schema (`default_layout` disk_config + `!root-password` / `!password` sentinel keys + `custom_commands` for post-install group joins). Install log page polls a JSON endpoint and renders a phase-based progress bar with a collapsible raw log. `FURTKA_DRY_RUN=1` skips the real exec for testing. - [x] **Wizard: account form → drive picker → overview → archinstall** — S1 collects hostname/user/password/language with validation, S2 picks boot drive, overview confirms, `/install/run` writes `user_configuration.json` + `user_credentials.json` (0600) and execs `archinstall --silent` against its 4.x schema (`default_layout` disk_config + `!root-password` / `!password` sentinel keys + `custom_commands` for post-install group joins). Install log page polls a JSON endpoint and renders a phase-based progress bar with a collapsible raw log. `FURTKA_DRY_RUN=1` skips the real exec for testing.
- [x] **mDNS `proksi.local`** — hostname baked into the live ISO, avahi + nss-mdns in the package list, advertised as soon as network-online fires. The HTTPS + local-CA half of this milestone is still open below. - [x] **mDNS `proksi.local`** — hostname baked into the live ISO, avahi + nss-mdns in the package list, advertised as soon as network-online fires. The HTTPS + local-CA half of this milestone is still open below.
- [ ] **Base OS post-install** — what Furtka actually looks like *after* the wizard writes config + reboots: Caddy + Authentik + app store. Robert's area. - [x] **Base OS post-install (demo level)** — after reboot the installed system comes up with Caddy on `:80` serving a Furtka landing page (welcome + live uptime/Docker/disk tiles), the console shows a banner pointing at `http://<hostname>.local`, and `nss-mdns` makes that URL resolve on the LAN. Written by `webinstaller/app.py`'s `_post_install_commands` via archinstall's `custom_commands`. No Authentik / no app store yet — that's the next milestone (Robert's area).
- [ ] Installer wizard screens S3S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built. - [ ] Installer wizard screens S3S7 — per-device purpose, network, domain, SSL, diagnostic. S5/S6 blocked on managed-gateway DNS infra not yet built.
- [ ] `https://proksi.local` with a local CA (today: plain HTTP at `http://proksi.local:5000`) - [ ] `https://proksi.local` with a local CA (today: plain HTTP at `http://proksi.local:5000`)
- [ ] Caddy + Authentik wired into first-boot bootstrap - [ ] Caddy + Authentik wired into first-boot bootstrap

View file

@ -46,6 +46,16 @@ mDNS (`proksi.local`) via avahi is installed but not yet wired. First milestone
5. Find its IP in Proxmox's VM summary (or your router's DHCP table) 5. Find its IP in Proxmox's VM summary (or your router's DHCP table)
6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there 6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there
## What you see after install + reboot
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:
- **Console**: agetty shows `Furtka is ready. Open http://<hostname>.local …` with the IP fallback underneath.
- **Browser** at `http://<hostname>.local` (default `http://proksi.local`): Caddy-served landing page with three live status tiles (uptime, Docker version, free disk) refreshed every 30 s by `furtka-status.timer`.
- **SSH**: `ssh <user>@<hostname>.local` works; `docker ps` works without `sudo` because the user is in the `docker` group.
This is a demo shell — no Authentik, no app store yet. The landing page lives at `/srv/furtka/www/`, served by Caddy on `:80` per `/etc/caddy/Caddyfile`. All of this is written into the target by `webinstaller/app.py`'s `_post_install_commands` via archinstall's `custom_commands`.
## Known rough edges ## Known rough edges
- **Disk space**: the first time you build on a fresh host, the squashfs/xorriso steps need ~15 GB free. If the host's LVM-root is smaller, `xorriso` silently dies at the very end with "Image size exceeds free space on media". - **Disk space**: the first time you build on a fresh host, the squashfs/xorriso steps need ~15 GB free. If the host's LVM-root is smaller, `xorriso` silently dies at the very end with "Image size exceeds free space on media".

View file

@ -60,7 +60,51 @@ def test_build_archinstall_config_uses_selected_locale(monkeypatch):
# `!password` sentinel; config only carries a gpasswd in custom_commands # `!password` sentinel; config only carries a gpasswd in custom_commands
# so the user lands in the docker group after docker is pacstrapped. # so the user lands in the docker group after docker is pacstrapped.
assert "users" not in cfg assert "users" not in cfg
assert any("gpasswd -a u docker" in c for c in cfg["custom_commands"]) assert cfg["custom_commands"][0] == "gpasswd -a u docker"
def test_build_archinstall_config_includes_post_install_bootstrap(monkeypatch):
# The installed system should come up with a Furtka landing page at
# http://<hostname>.local. That means caddy + avahi pacstrapped, the
# matching services enabled, a Caddyfile + index.html written into the
# target rootfs, and nss-mdns spliced into nsswitch.conf.
import app as app_module
monkeypatch.setattr(app_module, "build_disk_config", lambda d: {"stubbed_device": d})
cfg = build_archinstall_config(
{
"hostname": "heimserver",
"username": "u",
"password": "pw12345678",
"language": "en",
"boot_drive": "/dev/sda",
}
)
for pkg in ("caddy", "avahi", "nss-mdns"):
assert pkg in cfg["packages"]
for svc in ("caddy", "avahi-daemon", "furtka-welcome", "furtka-status.timer"):
assert svc in cfg["services"]
joined = "\n".join(cfg["custom_commands"])
for path in (
"/etc/caddy/Caddyfile",
"/srv/furtka/www/index.html",
"/srv/furtka/www/style.css",
"/srv/furtka/www/status.json",
"/usr/local/bin/furtka-status",
"/usr/local/bin/furtka-welcome",
"/etc/systemd/system/furtka-status.service",
"/etc/systemd/system/furtka-status.timer",
"/etc/systemd/system/furtka-welcome.service",
):
assert path in joined, f"expected {path} to be written by custom_commands"
assert "mdns_minimal" in joined
assert "nsswitch.conf" in joined
# The chosen hostname is pinned into the static HTML at install-time.
assert "s/__HOSTNAME__/heimserver/g" in joined
def test_build_archinstall_creds_uses_archinstall_sentinel_keys(): def test_build_archinstall_creds_uses_archinstall_sentinel_keys():

View file

@ -1,3 +1,4 @@
import base64
import json import json
import os import os
import re import re
@ -130,6 +131,327 @@ def build_disk_config(boot_drive):
return layout.json() return layout.json()
# ---------------------------------------------------------------------------
# Post-install bootstrap payload
#
# Written into the target system via archinstall's `custom_commands` so that
# after reboot the user lands in "Furtka": Caddy serves a branded landing
# page + live status tiles on :80, avahi advertises proksi.local, and the
# console shows a welcome banner pointing at the URL.
#
# Files are shipped inline (base64-encoded) rather than copied from the live
# ISO because archinstall's chroot can't see the live filesystem. Payload is
# small (~200 lines across 9 files) so this is cheaper than a tarball dance.
# ---------------------------------------------------------------------------
_CADDYFILE = """\
# Serves the Furtka landing page + status.json on :80. Static only for now;
# reverse_proxy / TLS / auth come later when Authentik is wired in.
:80 {
\troot * /srv/furtka/www
\tfile_server
\tencode gzip
\tlog {
\t\toutput stdout
\t}
}
"""
_INDEX_HTML = """\
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Furtka</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="stylesheet" href="/style.css">
</head>
<body>
<main>
<header>
<h1>Welcome to Furtka</h1>
<p class="lead">Your home server is ready.</p>
<p class="host">Running on <code>__HOSTNAME__</code></p>
</header>
<section class="status">
<h2>System status</h2>
<div class="tiles">
<div class="tile">
<span class="label">Uptime</span>
<span class="value" id="uptime"></span>
</div>
<div class="tile">
<span class="label">Docker</span>
<span class="value" id="docker"></span>
</div>
<div class="tile">
<span class="label">Free disk</span>
<span class="value" id="disk"></span>
</div>
</div>
<p class="updated">Updated <span id="updated"></span></p>
</section>
<section class="soon">
<h2>App store</h2>
<p>Coming soon one-click installs for Nextcloud, Jellyfin, and friends.</p>
</section>
<footer>
<p>Furtka · <a href="https://furtka.org">furtka.org</a></p>
</footer>
</main>
<script>
async function refresh() {
try {
const r = await fetch('/status.json', {cache: 'no-store'});
if (!r.ok) return;
const s = await r.json();
document.getElementById('uptime').textContent = s.uptime || '';
document.getElementById('docker').textContent = s.docker_version || '';
document.getElementById('disk').textContent = s.disk_free || '';
document.getElementById('updated').textContent = s.updated_at || '';
} catch (e) {
/* next tick will retry */
}
}
refresh();
setInterval(refresh, 15000);
</script>
</body>
</html>
"""
_STYLE_CSS = """\
:root {
--bg: #0f1115;
--fg: #e8eaed;
--muted: #9aa0a6;
--accent: #6ee7b7;
--card: #1a1d24;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
}
main { max-width: 780px; margin: 0 auto; padding: 4rem 1.5rem; }
header h1 { margin: 0 0 0.5rem; font-size: 2.5rem; }
.lead { font-size: 1.25rem; color: var(--muted); margin: 0 0 0.25rem; }
.host { color: var(--muted); margin: 0 0 3rem; }
.host code {
background: var(--card);
padding: 0.15rem 0.5rem;
border-radius: 4px;
color: var(--accent);
}
section h2 {
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin: 2rem 0 1rem;
}
.tiles {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.tile {
background: var(--card);
padding: 1.25rem;
border-radius: 8px;
display: flex;
flex-direction: column;
}
.tile .label {
font-size: 0.8rem;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.tile .value { font-size: 1.25rem; margin-top: 0.5rem; }
.updated { font-size: 0.85rem; color: var(--muted); margin-top: 1rem; }
.soon {
background: var(--card);
padding: 1.5rem;
border-radius: 8px;
margin-top: 2rem;
}
footer {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid #2a2e36;
color: var(--muted);
font-size: 0.9rem;
}
footer a { color: var(--accent); }
"""
_STATUS_JSON_PLACEHOLDER = """\
{
"hostname": "",
"uptime": "starting…",
"docker_version": "starting…",
"disk_free": "starting…",
"updated_at": ""
}
"""
_FURTKA_STATUS_SH = """\
#!/bin/bash
# Writes /srv/furtka/www/status.json with current system stats. Fired by
# furtka-status.timer every 30s; also runs once 10s after boot.
set -e
out=/srv/furtka/www/status.json
tmp=$(mktemp)
hostname=$(cat /etc/hostname)
uptime=$(uptime -p 2>/dev/null | sed 's/^up //' || echo unknown)
if command -v docker >/dev/null 2>&1; then
docker_version=$(docker --version 2>/dev/null \
| awk '{print $3}' | tr -d ',' || echo unavailable)
else
docker_version=unavailable
fi
disk_free=$(df -h / 2>/dev/null | awk 'NR==2 {print $4 " free of " $2}' || echo unknown)
updated_at=$(date -Iseconds)
cat > "$tmp" <<EOF
{
"hostname": "$hostname",
"uptime": "$uptime",
"docker_version": "$docker_version",
"disk_free": "$disk_free",
"updated_at": "$updated_at"
}
EOF
mv "$tmp" "$out"
chmod 644 "$out"
"""
_FURTKA_STATUS_SERVICE = """\
[Unit]
Description=Refresh Furtka system status JSON
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/furtka-status
"""
_FURTKA_STATUS_TIMER = """\
[Unit]
Description=Refresh Furtka system status every 30s
[Timer]
OnBootSec=10s
OnUnitActiveSec=30s
AccuracySec=5s
[Install]
WantedBy=timers.target
"""
_FURTKA_WELCOME_SH = """\
#!/bin/bash
# Regenerates /etc/issue on the installed system so the console tells the
# user which URL to open. Mirrors the live-ISO furtka-update-issue pattern.
set -e
hostname=$(cat /etc/hostname)
ip=$(ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
{
echo
echo " Furtka is ready."
echo
echo " Open in a browser on another device on your network:"
echo
echo " http://${hostname}.local (easy — try this first)"
if [ -n "$ip" ]; then
echo " http://${ip} (fallback if the first doesn't work)"
fi
echo
} > /etc/issue
agetty --reload 2>/dev/null || true
"""
_FURTKA_WELCOME_SERVICE = """\
[Unit]
Description=Furtka console welcome banner
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/furtka-welcome
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
"""
def _write_file_cmd(path, content, mode=None):
"""Shell command that recreates `path` with `content` inside the chroot.
Uses base64 so we don't have to worry about bash / JSON / archinstall
quoting the payload through three layers of shell. `base64` is part of
coreutils and always available in the target system.
"""
b64 = base64.b64encode(content.encode()).decode()
parent = path.rsplit("/", 1)[0]
cmd = f"mkdir -p {parent} && printf %s {b64} | base64 -d > {path}"
if mode is not None:
cmd += f" && chmod {mode} {path}"
return cmd
def _post_install_commands(hostname):
# nss-mdns: splice `mdns_minimal [NOTFOUND=return]` before `resolve` on
# the hosts line so `*.local` works from the installed system too. Guarded
# so a re-run (or a future Arch default that already ships mdns) is a
# no-op instead of double-injecting.
nss_sed = (
"grep -q 'mdns_minimal' /etc/nsswitch.conf || "
"sed -i '/^hosts:/ s/resolve/mdns_minimal [NOTFOUND=return] resolve/' "
"/etc/nsswitch.conf"
)
# Pin the chosen hostname into the static HTML at install-time so the
# landing page doesn't need a server-side template.
hostname_sed = (
f"sed -i 's/__HOSTNAME__/{hostname}/g' /srv/furtka/www/index.html"
)
return [
_write_file_cmd("/etc/caddy/Caddyfile", _CADDYFILE),
_write_file_cmd("/srv/furtka/www/index.html", _INDEX_HTML),
_write_file_cmd("/srv/furtka/www/style.css", _STYLE_CSS),
_write_file_cmd("/srv/furtka/www/status.json", _STATUS_JSON_PLACEHOLDER),
_write_file_cmd("/usr/local/bin/furtka-status", _FURTKA_STATUS_SH, mode="755"),
_write_file_cmd("/usr/local/bin/furtka-welcome", _FURTKA_WELCOME_SH, mode="755"),
_write_file_cmd(
"/etc/systemd/system/furtka-status.service", _FURTKA_STATUS_SERVICE
),
_write_file_cmd(
"/etc/systemd/system/furtka-status.timer", _FURTKA_STATUS_TIMER
),
_write_file_cmd(
"/etc/systemd/system/furtka-welcome.service", _FURTKA_WELCOME_SERVICE
),
nss_sed,
hostname_sed,
]
def build_archinstall_config(s): def build_archinstall_config(s):
return { return {
"archinstall-language": "English", "archinstall-language": "English",
@ -139,14 +461,37 @@ def build_archinstall_config(s):
"disk_config": build_disk_config(s["boot_drive"]), "disk_config": build_disk_config(s["boot_drive"]),
"hostname": s["hostname"], "hostname": s["hostname"],
"kernels": ["linux"], "kernels": ["linux"],
"packages": ["docker", "docker-compose", "vim", "git", "htop", "curl"], "packages": [
"docker",
"docker-compose",
"vim",
"git",
"htop",
"curl",
# Base OS post-install (landing page + mDNS on installed system).
"caddy",
"avahi",
"nss-mdns",
],
"profile": {"type": "server"}, "profile": {"type": "server"},
"services": ["docker"], "services": [
# Add user to the docker group post-install. We can't put "docker" in "docker",
# the user's `groups` at create-time because archinstall creates users # Base OS post-install services. `furtka-welcome` refreshes
# before pacstrapping the extras, so the docker group doesn't exist # /etc/issue with the landing-page URL; `furtka-status.timer`
# yet. custom_commands runs at the very end. # keeps /srv/furtka/www/status.json fresh for the dashboard.
"custom_commands": [f"gpasswd -a {s['username']} docker"], "caddy",
"avahi-daemon",
"furtka-welcome",
"furtka-status.timer",
],
# `gpasswd -a <user> docker` has to stay first — adds the user to
# the docker group once the group exists (archinstall creates users
# before pacstrapping extras). After that we drop the Furtka landing
# page, status timer, and welcome banner into place.
"custom_commands": [
f"gpasswd -a {s['username']} docker",
*_post_install_commands(s["hostname"]),
],
"network_config": {"type": "iso"}, "network_config": {"type": "iso"},
"ssh": True, "ssh": True,
"audio_config": None, "audio_config": None,