# ruff: noqa: E501 — inline HTML/CSS/JS payloads (_INDEX_HTML, _STYLE_CSS, # _CADDYFILE, _FURTKA_STATUS_SH, etc.) round-trip verbatim to the installed # system; wrapping them hurts readability and the rendered output is what # matters. 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 = """\ Furtka

Welcome to Furtka

Your home server is ready.

Running on __HOSTNAME__

Your apps

System status

Uptime
Docker
Free disk

Updated

Coming next

Features we're building — follow progress on furtka.org.

Photos Smart home Media streaming Multiple boxes Secure link User accounts
""" _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
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 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; } """ _SETTINGS_HTML = """\ Settings · Furtka

Settings

What this box knows about itself.

About this box

Hostname
IP address
Furtka version
Kernel
RAM
Docker
Uptime

Appearance

Theme
Follows your system setting
Language
English

Coming next

Controls we're building — follow progress on furtka.org.

Reboot Shut down Change hostname Backup User accounts Remote access
""" _STATUS_JSON_PLACEHOLDER = """\ { "hostname": "", "uptime": "starting…", "docker_version": "starting…", "disk_free": "starting…", "ip_primary": "", "kernel": "", "ram_total": "", "furtka_version": "", "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) ip_primary=$(ip -4 -o addr show scope global 2>/dev/null \ | awk '{print $4}' | cut -d/ -f1 | head -1 || true) kernel=$(uname -r 2>/dev/null || echo unknown) ram_total=$(free -h --si 2>/dev/null | awk '/^Mem:/ {print $2}' || echo unknown) # TODO(furtka-version): wire into the installer so /etc/furtka/version # gets pinned at install time. Until then the settings page shows "dev". furtka_version=$(cat /etc/furtka/version 2>/dev/null || echo dev) 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 """ # 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/settings/index.html", _SETTINGS_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 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)