feat: post-install bootstrap — land in Furtka after reboot
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:
parent
dfdbdd69aa
commit
8ed1d82fd3
4 changed files with 408 additions and 9 deletions
|
|
@ -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 S3–S7 — 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
|
||||
|
|
|
|||
|
|
@ -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".
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue