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] **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.
- [ ] **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.
- [ ] `https://proksi.local` with a local CA (today: plain HTTP at `http://proksi.local:5000`)
- [ ] 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)
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
- **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
# so the user lands in the docker group after docker is pacstrapped.
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():

View file

@ -1,3 +1,4 @@
import base64
import json
import os
import re
@ -130,6 +131,327 @@ def build_disk_config(boot_drive):
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):
return {
"archinstall-language": "English",
@ -139,14 +461,37 @@ def build_archinstall_config(s):
"disk_config": build_disk_config(s["boot_drive"]),
"hostname": s["hostname"],
"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"},
"services": ["docker"],
# Add user to the docker group post-install. We can't put "docker" in
# the user's `groups` at create-time because archinstall creates users
# before pacstrapping the extras, so the docker group doesn't exist
# yet. custom_commands runs at the very end.
"custom_commands": [f"gpasswd -a {s['username']} docker"],
"services": [
"docker",
# Base OS post-install services. `furtka-welcome` refreshes
# /etc/issue with the landing-page URL; `furtka-status.timer`
# keeps /srv/furtka/www/status.json fresh for the dashboard.
"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"},
"ssh": True,
"audio_config": None,