diff --git a/README.md b/README.md index e823c4f..e23cdf4 100644 --- a/README.md +++ b/README.md @@ -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://.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 diff --git a/iso/README.md b/iso/README.md index 13fd166..c52a277 100644 --- a/iso/README.md +++ b/iso/README.md @@ -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://: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://.local …` with the IP fallback underneath. +- **Browser** at `http://.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 @.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". diff --git a/tests/test_app.py b/tests/test_app.py index a80b68c..7e71df0 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -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://.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(): diff --git a/webinstaller/app.py b/webinstaller/app.py index e0248d9..96345cd 100644 --- a/webinstaller/app.py +++ b/webinstaller/app.py @@ -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 = """\ + + + + + Furtka + + + + +
+
+

Welcome to Furtka

+

Your home server is ready.

+

Running on __HOSTNAME__

+
+ +
+

System status

+
+
+ Uptime + +
+
+ Docker + +
+
+ Free disk + +
+
+

Updated

+
+ +
+

App store

+

Coming soon — one-click installs for Nextcloud, Jellyfin, and friends.

+
+ + +
+ + + + +""" + +_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" </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 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,