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__

System status

Uptime
Docker
Free disk

Updated

Apps

Manage installed apps →

""" _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 """ # 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 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)