furtka/webinstaller/app.py
Daniel Maksymilian Syrnicki a6878f5d23 feat(ui): shared /style.css + top nav across landing and /apps
Slice 1 of the on-box UI uplevel. Consolidates the two duplicated
stylesheets (landing's webinstaller/app.py and /apps's inline block
in furtka/api.py) into one sheet served by Caddy at /style.css.
Expands the token set (spacing, radii, shadows, focus ring, warn-fg,
accent-soft, card-hover), adds a prefers-color-scheme light theme,
and introduces shared primitives for later slices: .nav, .chip,
.card, .kv, .coming, .grid-apps, .app-tile, .app-icon.

Also adds a persistent top nav (Home / Apps) to both pages — Jakob's
Law, so users always have a way back — and collapses the /apps "Last
action" log behind a details disclosure so it stops dominating the
page. Format fallout on drives.py picked up by ruff.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 12:19:54 +02:00

1098 lines
33 KiB
Python

import base64
import json
import os
import re
import subprocess
import sys
from pathlib import Path
from drives import list_scored_devices
from flask import Flask, jsonify, redirect, render_template, request, url_for
app = Flask(__name__)
LANGUAGES = {
"en": {"locale": "en_US.UTF-8", "label": "English", "keyboard": "us"},
"de": {"locale": "de_DE.UTF-8", "label": "Deutsch", "keyboard": "de"},
"pl": {"locale": "pl_PL.UTF-8", "label": "Polski", "keyboard": "pl"},
}
STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/tmp/furtka"))
INSTALL_LOG = STATE_DIR / "install.log"
CONFIG_PATH = STATE_DIR / "user_configuration.json"
CREDS_PATH = STATE_DIR / "user_credentials.json"
# Pre-populated with sane defaults so the form has something useful on first
# render. POSTs validate and overwrite.
settings = {
"hostname": "furtka",
"username": "",
"password": "",
"language": "en",
"boot_drive": "",
}
HOSTNAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
USERNAME_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
# Ordered phase markers for the install progress bar. Each tuple is
# (substring to search for in the archinstall log, progress percent when
# reached, user-facing label). Pick the furthest phase whose marker is
# present in the log. If archinstall changes its stdout wording the bar
# stalls on the last recognized phase — the install itself keeps going.
PROGRESS_PHASES = [
("Wiping partitions", 8, "Preparing your disk"),
("Creating partitions", 12, "Creating partitions"),
("Starting installation", 15, "Starting installation"),
("Waiting for", 18, "Syncing time and packages"),
("Installing packages: ['base'", 25, "Installing the base system (this takes a while)"),
("Adding bootloader", 65, "Setting up boot"),
("Installing packages: ['efibootmgr'", 70, "Setting up boot"),
("Installing packages: ['docker'", 80, "Installing your apps"),
("Enabling service", 90, "Turning on services"),
("Updating /mnt/etc/fstab", 95, "Almost done"),
("Installation completed without any errors", 100, "Done!"),
]
PROGRESS_ERROR_MARKERS = ("Traceback (most recent call last)", "archinstall: error:")
def parse_install_progress(log):
percent = 2
phase = "Starting up…"
for marker, pct, label in PROGRESS_PHASES:
if marker in log:
percent = pct
phase = label
if percent >= 100:
status = "done"
elif any(m in log for m in PROGRESS_ERROR_MARKERS):
status = "error"
phase = "Installation failed — open Show details below"
else:
status = "running"
return {"percent": percent, "phase": phase, "status": status}
def validate_step1(form):
errors = []
values = {
"hostname": form.get("hostname", "").strip(),
"username": form.get("username", "").strip(),
"password": form.get("password", ""),
"language": form.get("language", ""),
}
password2 = form.get("password2", "")
if not HOSTNAME_RE.match(values["hostname"]):
errors.append("Hostname must be lowercase letters, digits, hyphens (max 63 chars).")
if not USERNAME_RE.match(values["username"]):
errors.append("Username must start with a letter or underscore, lowercase only.")
if len(values["password"]) < 8:
errors.append("Password must be at least 8 characters.")
if values["password"] != password2:
errors.append("Passwords do not match.")
if values["language"] not in LANGUAGES:
errors.append("Pick a language.")
return errors, values
def build_disk_config(boot_drive):
# archinstall 4.x dropped the `use_entire_disk` shortcut — `default_layout`
# now requires fully-specified partitions. We call suggest_single_disk_layout
# with ext4 + no separate /home, which short-circuits its interactive prompts.
import asyncio
from archinstall.lib.disk.device_handler import device_handler
from archinstall.lib.disk.disk_menu import suggest_single_disk_layout
from archinstall.lib.models.device import (
DiskLayoutConfiguration,
DiskLayoutType,
FilesystemType,
)
device_handler.load_devices()
device = device_handler.get_device(Path(boot_drive))
if device is None:
raise RuntimeError(f"archinstall could not resolve device {boot_drive!r}")
device_mod = asyncio.run(
suggest_single_disk_layout(
device,
filesystem_type=FilesystemType.Ext4,
separate_home=False,
)
)
layout = DiskLayoutConfiguration(
config_type=DiskLayoutType.Default,
device_modifications=[device_mod],
)
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 for the
# landing page; /apps and /api are reverse-proxied to the local resource-
# manager API (furtka serve, bound to 127.0.0.1:7000). TLS / auth come
# later when Authentik is wired in.
:80 {
\thandle /api/* {
\t\treverse_proxy localhost:7000
\t}
\thandle /apps* {
\t\treverse_proxy localhost:7000
\t}
\thandle {
\t\troot * /srv/furtka/www
\t\tfile_server
\t\tencode gzip
\t}
\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 class="wrap">
<nav class="nav">
<a class="brand" href="/">Furtka</a>
<div class="nav-links">
<a href="/" aria-current="page">Home</a>
<a href="/apps">Apps</a>
</div>
</nav>
<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>Apps</h2>
<p><a href="/apps">Manage installed apps →</a></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 = """\
/* Furtka on-box design system. Served by Caddy at /style.css,
consumed by the landing page AND the resource-manager /apps
page. One source of truth for tokens + components. */
:root {
--bg: #0f1115;
--fg: #e8eaed;
--muted: #9aa0a6;
--accent: #6ee7b7;
--accent-soft: rgba(110, 231, 183, 0.12);
--card: #1a1d24;
--card-hover: #222530;
--border: #2a2d34;
--warn: #4a3030;
--warn-fg: #fed;
--danger: #f08080;
--r-sm: 4px;
--r-md: 8px;
--r-lg: 12px;
--r-pill: 999px;
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3);
--ring: 0 0 0 2px var(--accent);
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f7f6f3;
--fg: #17181c;
--muted: #5e6066;
--accent: #0f8a5f;
--accent-soft: rgba(15, 138, 95, 0.12);
--card: #ffffff;
--card-hover: #f0efeb;
--border: #e3e1dc;
--warn: #fde2d3;
--warn-fg: #5a2a10;
--danger: #c03a28;
--shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08);
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--fg);
line-height: 1.5;
}
/* Shared page container — both landing and /apps wrap content in
<main class="wrap"> so sizing + padding stay consistent. */
.wrap { max-width: 780px; margin: 0 auto; padding: 1.25rem 1.5rem 3rem; }
/* Top nav — persistent across pages (Jakob's Law). */
.nav {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 1.25rem;
border-bottom: 1px solid var(--border);
margin-bottom: 2rem;
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
color: var(--fg);
text-decoration: none;
font-size: 1.05rem;
display: inline-flex;
align-items: center;
gap: 0.55rem;
}
.brand::before {
content: "";
width: 0.7rem;
height: 0.7rem;
background: var(--accent);
border-radius: 2px;
transform: rotate(45deg);
}
.nav-links { display: flex; gap: 0.25rem; }
.nav-links a {
color: var(--muted);
text-decoration: none;
font-size: 0.9rem;
padding: 0.35rem 0.75rem;
border-radius: var(--r-sm);
}
.nav-links a:hover { color: var(--fg); }
.nav-links a[aria-current="page"] {
color: var(--fg);
background: var(--accent-soft);
}
/* -- Landing page ---------------------------------------------- */
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: var(--r-sm);
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: var(--r-md);
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: var(--r-md);
margin-top: 2rem;
}
footer {
margin-top: 4rem;
padding-top: 1.5rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.9rem;
}
footer a { color: var(--accent); }
/* -- Apps page ------------------------------------------------- */
h1 { font-size: 2rem; margin: 0; }
h2 {
font-size: 1rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
margin: 2rem 0 0.75rem;
}
.lede { color: var(--muted); margin: 0.25rem 0 1rem; }
.warn {
background: var(--warn);
padding: 1rem;
border-radius: var(--r-md);
margin: 1.5rem 0;
color: var(--warn-fg);
font-size: 0.9rem;
}
.app {
background: var(--card);
padding: 1rem;
border-radius: var(--r-md);
margin: 0.5rem 0;
display: flex;
justify-content: space-between;
align-items: center;
gap: 1rem;
box-shadow: var(--shadow-card);
}
.app .left {
display: flex;
align-items: center;
gap: 1rem;
min-width: 0;
flex: 1;
}
.meta { display: flex; flex-direction: column; min-width: 0; }
.name { font-weight: 600; font-size: 1.05rem; }
.name small { color: var(--muted); font-weight: 400; margin-left: 0.5rem; }
.desc {
color: var(--muted);
font-size: 0.9rem;
overflow: hidden;
text-overflow: ellipsis;
}
.buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
button {
background: var(--accent);
border: none;
color: var(--bg);
font-weight: 600;
padding: 0.5rem 1rem;
border-radius: var(--r-sm);
cursor: pointer;
white-space: nowrap;
font-size: 0.9rem;
font-family: inherit;
}
button.secondary {
background: var(--card);
color: var(--fg);
border: 1px solid var(--border);
}
button.danger { background: var(--danger); color: #fff; }
button:disabled { opacity: 0.5; cursor: wait; }
button:focus-visible { outline: none; box-shadow: var(--ring); }
.empty { color: var(--muted); font-style: italic; padding: 0.5rem 0; }
pre {
background: var(--card);
padding: 1rem;
border-radius: var(--r-md);
overflow-x: auto;
font-size: 0.85rem;
white-space: pre-wrap;
word-wrap: break-word;
}
details.log-details {
margin-top: 0.25rem;
}
details.log-details > summary {
cursor: pointer;
color: var(--muted);
font-size: 0.9rem;
padding: 0.25rem 0;
user-select: none;
}
details.log-details[open] > summary { color: var(--fg); }
/* Modal */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: none;
align-items: flex-start;
justify-content: center;
padding: 2rem 1rem;
overflow-y: auto;
z-index: 10;
}
.modal-backdrop.open { display: flex; }
.modal {
background: var(--card);
border-radius: var(--r-md);
padding: 1.5rem;
max-width: 520px;
width: 100%;
}
.modal h3 { margin: 0 0 0.5rem; font-size: 1.3rem; }
.modal .long {
color: var(--muted);
font-size: 0.9rem;
margin-bottom: 1.25rem;
white-space: pre-wrap;
}
.field { margin-bottom: 1rem; }
.field label {
display: block;
font-weight: 600;
margin-bottom: 0.25rem;
font-size: 0.95rem;
}
.field .hint { color: var(--muted); font-size: 0.85rem; margin-bottom: 0.35rem; }
.field input {
width: 100%;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--r-sm);
padding: 0.5rem 0.6rem;
font-size: 0.95rem;
font-family: inherit;
}
.field input:focus { outline: 2px solid var(--accent); outline-offset: -1px; }
.field .req { color: var(--danger); margin-left: 0.25rem; }
.modal .error {
background: var(--warn);
color: var(--warn-fg);
padding: 0.5rem 0.75rem;
border-radius: var(--r-sm);
margin-bottom: 1rem;
font-size: 0.9rem;
display: none;
}
.modal .error.show { display: block; }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.5rem;
}
/* -- Shared primitives for later slices ------------------------ */
.chip {
display: inline-block;
background: var(--card);
color: var(--accent);
padding: 0.15rem 0.6rem;
border-radius: var(--r-pill);
font-size: 0.8rem;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.chip-muted { color: var(--muted); }
.card {
background: var(--card);
padding: 1.25rem;
border-radius: var(--r-md);
box-shadow: var(--shadow-card);
}
.card + .card { margin-top: 1rem; }
.card h3 { margin: 0 0 0.75rem; font-size: 1.05rem; }
.kv {
display: grid;
grid-template-columns: max-content 1fr;
column-gap: 1.25rem;
row-gap: 0.4rem;
font-size: 0.95rem;
}
.kv dt { color: var(--muted); }
.kv dd { margin: 0; color: var(--fg); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.coming {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
}
.coming a {
color: var(--muted);
text-decoration: none;
padding: 0.3rem 0.8rem;
border-radius: var(--r-pill);
border: 1px solid var(--border);
font-size: 0.85rem;
}
.coming a:hover { color: var(--fg); border-color: var(--accent); }
.coming .hint {
color: var(--muted);
font-size: 0.85rem;
width: 100%;
margin: 0 0 0.25rem;
}
.grid-apps {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 0.75rem;
}
.app-tile {
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--r-md);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
align-items: flex-start;
text-decoration: none;
color: var(--fg);
transition: border-color 120ms, background 120ms;
}
.app-tile:hover { border-color: var(--accent); background: var(--card-hover); }
.app-tile .icon {
width: 40px;
height: 40px;
color: var(--accent);
display: flex;
align-items: center;
justify-content: center;
}
.app-tile .icon svg { width: 100%; height: 100%; }
.app-tile .name { font-weight: 600; font-size: 0.95rem; }
.app-tile .cta { color: var(--accent); font-size: 0.85rem; }
/* Icon slot inside a /apps row. The app icon inherits currentColor
so a folder path rendered with fill="currentColor" picks up the
accent, while a nested <path> using stroke="var(--accent)" still
gets the brand color. */
.app-icon {
width: 56px;
height: 56px;
flex-shrink: 0;
background: var(--accent-soft);
border-radius: var(--r-md);
display: flex;
align-items: center;
justify-content: center;
color: var(--accent);
}
.app-icon svg { width: 36px; height: 36px; }
"""
_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
"""
# Tarball built by iso/build.sh containing the furtka/ Python package + the
# bundled apps/ tree. The webinstaller reads it from the live ISO at
# request-time and base64-encodes it into a custom_command for archinstall.
RESOURCE_MANAGER_PAYLOAD = Path("/opt/furtka-resource-manager.tar.gz")
_FURTKA_WRAPPER_SH = """\
#!/bin/sh
# Tiny launcher for the furtka resource-manager CLI. The Python source lives
# under /opt/furtka/furtka/ — added to PYTHONPATH so plain `python3 -m` finds
# it without needing pip on the target system.
PYTHONPATH=/opt/furtka exec python3 -m furtka.cli "$@"
"""
_FURTKA_RECONCILE_SERVICE = """\
[Unit]
Description=Furtka app reconciler (boot-scan)
Requires=docker.service
After=docker.service network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/furtka reconcile
RemainAfterExit=no
[Install]
WantedBy=multi-user.target
"""
_FURTKA_API_SERVICE = """\
[Unit]
Description=Furtka resource-manager HTTP API + UI
Requires=docker.service
After=docker.service network-online.target furtka-reconcile.service
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/furtka serve --host 127.0.0.1 --port 7000
Restart=on-failure
RestartSec=5
[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 _resource_manager_commands():
"""Commands to land /opt/furtka/ + the `furtka` CLI + reconcile.service.
Reads the payload tarball staged into the live ISO at build time. If the
file isn't present (dev box without an ISO build), returns [] so the rest
of the install still works — the resource manager just won't be installed.
"""
if not RESOURCE_MANAGER_PAYLOAD.exists():
print(
f"warning: {RESOURCE_MANAGER_PAYLOAD} missing, "
"resource manager will NOT be installed on target",
file=sys.stderr,
)
return []
payload_b64 = base64.b64encode(RESOURCE_MANAGER_PAYLOAD.read_bytes()).decode()
untar_cmd = (
f"mkdir -p /opt/furtka && printf %s {payload_b64} | base64 -d | tar -xzf - -C /opt/furtka"
)
return [
untar_cmd,
_write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"),
_write_file_cmd("/etc/systemd/system/furtka-reconcile.service", _FURTKA_RECONCILE_SERVICE),
_write_file_cmd("/etc/systemd/system/furtka-api.service", _FURTKA_API_SERVICE),
]
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,
*_resource_manager_commands(),
# archinstall calls `systemctl enable` on `services` *before*
# custom_commands runs, so our own unit files aren't on disk yet at
# that point. Enable them here, after they exist. caddy /
# avahi-daemon stay in the `services` list — those are packaged
# units, present right after pacstrap. furtka-reconcile +
# furtka-api are enabled only if the resource manager payload was
# actually installed above; the conditional keeps systemctl green
# on dev / payload-less builds.
"systemctl enable furtka-welcome.service furtka-status.timer "
"$([ -e /etc/systemd/system/furtka-reconcile.service ] "
"&& echo furtka-reconcile.service furtka-api.service)",
]
def _detect_bootloader():
# 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"
def build_archinstall_config(s):
return {
"archinstall-language": "English",
"timezone": "Europe/Berlin",
"ntp": True,
"bootloader": _detect_bootloader(),
"disk_config": build_disk_config(s["boot_drive"]),
"hostname": s["hostname"],
"kernels": ["linux"],
"packages": [
"docker",
"docker-compose",
# Editors for console/SSH recovery — `nano` is the beginner-friendly
# one, `vim` stays because it's muscle-memory for the dev team.
"nano",
"vim",
"git",
"htop",
"curl",
# Remote access — archinstall 4.x's `ssh: True` flag is flaky about
# actually pulling in openssh, so list it explicitly and enable sshd
# via `services` below. Without this, the documented recovery path
# (SSH in → edit .env) doesn't work.
"openssh",
# Base OS post-install (landing page + mDNS on installed system).
"caddy",
"avahi",
"nss-mdns",
# Resource manager runtime — pure-stdlib Python, no pip needed
# because we expose the package via PYTHONPATH in /usr/local/bin/furtka.
"python",
],
"profile": {"type": "server"},
"services": [
"docker",
# Base OS post-install services. Only packaged units go here —
# archinstall runs `systemctl enable` on this list *before*
# custom_commands, so our own furtka-welcome + furtka-status.timer
# units (written in custom_commands) are enabled there instead.
"caddy",
"avahi-daemon",
"sshd",
],
# `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,
"locale_config": {
"locale": LANGUAGES[s["language"]]["locale"],
# Keyboard layout follows the chosen language so a German user
# doesn't get a US layout at the TTY console (where things like
# `/`, `-`, `=` land on surprising keys and make even `sudo vim`
# painful). `en` falls through to "us" which is what we want.
"keyboard_layout": LANGUAGES[s["language"]]["keyboard"],
},
}
def build_archinstall_creds(s):
# archinstall 4.x expects `!root-password` and `!password` (plaintext
# sentinels). Users with neither `!password` nor `enc_password` are
# silently dropped by User.parse_arguments — hence login failures.
return {
"!root-password": s["password"],
"users": [
{
"username": s["username"],
"!password": s["password"],
"sudo": True,
"groups": [],
}
],
}
def write_install_files(s, state_dir):
state_dir.mkdir(parents=True, exist_ok=True)
config_path = state_dir / "user_configuration.json"
creds_path = state_dir / "user_credentials.json"
config_path.write_text(json.dumps(build_archinstall_config(s), indent=2))
creds_path.write_text(json.dumps(build_archinstall_creds(s), indent=2))
creds_path.chmod(0o600)
return config_path, creds_path
def spawn_archinstall(config_path, creds_path, log_path):
log_fh = open(log_path, "wb")
return subprocess.Popen(
[
"archinstall",
"--config",
str(config_path),
"--creds",
str(creds_path),
"--silent",
],
stdout=log_fh,
stderr=subprocess.STDOUT,
start_new_session=True,
)
@app.route("/")
def home():
return redirect(url_for("install_step_1"))
@app.route("/install/step1", methods=["GET", "POST"])
def install_step_1():
errors = []
if request.method == "POST":
errors, values = validate_step1(request.form)
if not errors:
settings.update(values)
return redirect(url_for("install_step_2"))
form_values = values
else:
form_values = {k: settings[k] for k in ("hostname", "username", "language")}
return render_template(
"install/step1.html",
values=form_values,
languages=LANGUAGES,
errors=errors,
)
@app.route("/install/step2", methods=["GET", "POST"])
def install_step_2():
if request.method == "POST":
boot_drive = request.form.get("boot_drive", "").strip()
if boot_drive:
settings["boot_drive"] = boot_drive
return redirect(url_for("install_overview"))
return render_template(
"install/step2.html",
drives=list_scored_devices(),
selected=settings.get("boot_drive", ""),
)
@app.route("/install/overview")
def install_overview():
masked = {**settings, "password": "" * 8 if settings["password"] else ""}
return render_template("install/overview.html", settings=masked)
@app.route("/install/run", methods=["POST"])
def install_run():
if not settings["boot_drive"] or not settings["username"] or not settings["password"]:
return redirect(url_for("install_step_1"))
config_path, creds_path = write_install_files(settings, STATE_DIR)
INSTALL_LOG.write_bytes(b"")
if os.environ.get("FURTKA_DRY_RUN") == "1":
INSTALL_LOG.write_text(
f"DRY RUN: would exec archinstall --config {config_path} "
f"--creds {creds_path} --silent\n"
)
else:
spawn_archinstall(config_path, creds_path, INSTALL_LOG)
return redirect(url_for("install_log_view"))
@app.route("/install/log")
def install_log_view():
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
return render_template(
"install/log.html",
log=log,
progress=parse_install_progress(log),
)
@app.route("/install/log.json")
def install_log_json():
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
return jsonify(log=log, progress=parse_install_progress(log))
@app.route("/install/reboot", methods=["POST"])
def install_reboot():
# Only allow rebooting once the install has actually finished — we don't
# want a panicked click during install to reboot mid-pacstrap.
log = INSTALL_LOG.read_text() if INSTALL_LOG.exists() else ""
if parse_install_progress(log)["status"] != "done":
return redirect(url_for("install_log_view"))
# Delay reboot a few seconds so the browser can finish fetching CSS / assets
# for the rebooting page before the Flask server (and network) go away.
# Without this, the reboot page renders unstyled (giant inline SVG icon).
subprocess.Popen(
["/bin/sh", "-c", "sleep 3 && /usr/bin/systemctl reboot"],
start_new_session=True,
)
return render_template("install/rebooting.html", hostname=settings["hostname"])
if __name__ == "__main__":
app.run(debug=True, port=5000)