# 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 datetime import UTC from pathlib import Path from drives import list_scored_devices from flask import Flask, jsonify, redirect, render_template, request, url_for app = Flask(__name__) def _resolve_version() -> str: """Resolve the Furtka version to display in the wizard footer. On the live ISO `iso/build.sh` writes `/opt/furtka/VERSION` at build time from `pyproject.toml`; that's the authoritative source at runtime. For local dev runs (pytest, `flask run` outside the ISO) fall back to reading `pyproject.toml` directly, then to the literal "dev" so the footer never 500s if both files are missing. """ iso_path = Path(__file__).resolve().parent / "VERSION" for candidate in (iso_path, Path(__file__).resolve().parent.parent / "pyproject.toml"): try: text = candidate.read_text(encoding="utf-8") except (FileNotFoundError, PermissionError, OSError): continue if candidate.name == "VERSION": value = text.strip() if value: return value else: match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) if match: return match.group(1) return "dev" FURTKA_VERSION = _resolve_version() @app.context_processor def _inject_version(): return {"furtka_version": FURTKA_VERSION} 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, # archinstall renamed the enum members to ALL_CAPS at some point # between when we wrote this and the pinned Arch live ISO version. # The old name `Ext4` now raises AttributeError at install time. 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. # # Asset files (HTML, CSS, shell scripts, systemd units, Caddyfile) live in # assets/ in the repo — at ISO build time they end up on the live ISO # as part of the webinstaller's source tree AND inside the resource-manager # payload tarball. The installer reads them from the live-ISO copy, base64- # encodes them, and hands them to archinstall so the chroot recreates each # file bit-for-bit. Updates (Phase 2) refresh the tarball, which carries the # same assets to the target's /opt/furtka/ tree. # --------------------------------------------------------------------------- # Tarball built by iso/build.sh containing the furtka/ Python package + the # bundled apps/ tree (plus assets/). 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") # Asset root. Two layouts we have to handle: # dev / tests — webinstaller/app.py sits at repo_root/webinstaller/ and # assets live at repo_root/assets/. # live ISO — iso/build.sh copies webinstaller/ to /opt/furtka/ AND # copies assets/ to /opt/assets/ right next to # app.py, so the same "assets next to me" lookup works. # Probe the sibling path first (ISO case), fall back to the repo layout. def _resolve_assets_dir() -> Path: here = Path(__file__).resolve().parent sibling = here / "assets" if sibling.is_dir(): return sibling repo_copy = here.parent / "assets" if repo_copy.is_dir(): return repo_copy raise FileNotFoundError( f"furtka assets not found near {here} — looked in {sibling} and {repo_copy}" ) _ASSETS_DIR = _resolve_assets_dir() def _read_asset(relpath: str) -> str: """Return the UTF-8 contents of an on-disk asset shipped under assets/. Raises FileNotFoundError if the asset is missing, which is loud by design: an install that tries to write an asset that isn't there is broken before the user ever boots the target, not after. """ path = _ASSETS_DIR / relpath return path.read_text(encoding="utf-8") _FURTKA_WRAPPER_SH = """\ #!/bin/sh # Tiny launcher for the furtka resource-manager CLI. The Python source lives # under /opt/furtka/current/furtka/ — /current is a symlink that gets # flipped by self-updates (Phase 2), so this shim stays stable across # upgrades while the underlying code tree is swapped atomically. PYTHONPATH=/opt/furtka/current exec python3 -m furtka.cli "$@" """ 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 _FURTKA_UNITS = ( "furtka-api.service", "furtka-reconcile.service", "furtka-status.service", "furtka-status.timer", "furtka-welcome.service", # Daily apps-catalog pull. Timer drives the service; the .service itself # is oneshot and also callable ad-hoc via `furtka catalog sync`. "furtka-catalog-sync.service", "furtka-catalog-sync.timer", ) def _resource_manager_commands(): """Commands to land /opt/furtka/versions// + symlink /opt/furtka/current + the `furtka` CLI shim + systemctl-link the unit files. 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, and nothing else on the system references furtka-* units. """ 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() # Extract to a staging directory first, then rename to versions//. # That way the version-ID lookup is data-driven (reads VERSION from the # tarball) instead of hardcoded at install-time — keeps the installer # version-agnostic so a newer ISO doesn't need a webinstaller change to # ship a new Furtka version. extract_and_link = ( "mkdir -p /opt/furtka/versions && " "staging=$(mktemp -d /opt/furtka/versions/staging-XXXXXX) && " f'printf %s {payload_b64} | base64 -d | tar -xzf - -C "$staging" && ' 'ver=$(cat "$staging/VERSION") && ' # Guard against an empty VERSION file: without this, `mv "$staging" # "/opt/furtka/versions/"` would move the staging dir into versions/ # as a subdir and the symlink target would be invalid. '[ -n "$ver" ] || { echo "empty VERSION in payload" >&2; exit 1; } && ' 'mv "$staging" "/opt/furtka/versions/$ver" && ' # mktemp -d creates the staging dir with mode 700; that survives the # mv and leaves Caddy (which runs as the `caddy` user, not root) # unable to traverse /opt/furtka/current/ when it tries to serve # the landing page. Open up to 755 so file_server can read. 'chmod 755 "/opt/furtka/versions/$ver" && ' 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' ) systemctl_link = "systemctl link " + " ".join( f"/opt/furtka/current/assets/systemd/{u}" for u in _FURTKA_UNITS ) systemctl_enable = "systemctl enable " + " ".join(_FURTKA_UNITS) return [ extract_and_link, _write_file_cmd("/usr/local/bin/furtka", _FURTKA_WRAPPER_SH, mode="755"), systemctl_link, systemctl_enable, ] def _furtka_json_cmd(hostname): """Write /var/lib/furtka/furtka.json with install-time facts. Replaces the __HOSTNAME__ sed pass — the landing page reads this file at runtime and renders the hostname chip from it. install_date + version ride along so the settings page can display them without hitting the status timer's refresh cycle. Heredoc rather than base64 + sed — the previous version had two layers of quoting that archinstall's custom_commands shell-eval path parsed inconsistently, leaving this command as a silent no-op on some installs. The heredoc evaluates `$(date ...)` and `$(cat VERSION)` at chroot runtime and sidesteps the quoting hazard entirely. Hostname has already been validated by validate_step1. """ return ( "mkdir -p /var/lib/furtka && " "cat > /var/lib/furtka/furtka.json </dev/null || echo dev)"\n' "}\n" "EOF" ) def _users_json_cmd(username, password): """Write /var/lib/furtka/users.json with the admin account hashed. The core furtka-api reads this file on every login attempt; the auth.py module treats `admin.username` + `admin.hash` as the only credential. Hashing happens here in the webinstaller (werkzeug is a flask transitive dep so it's already installed in this environment) — the chroot doesn't need pip. Mode 0600 so nobody but root on the installed box can read the PBKDF2 hash. """ from datetime import datetime from werkzeug.security import generate_password_hash users = { "admin": { "username": username, "hash": generate_password_hash(password), "created_at": datetime.now(UTC).isoformat(timespec="seconds"), } } return _write_file_cmd( "/var/lib/furtka/users.json", json.dumps(users, indent=2) + "\n", mode="600", ) def _post_install_commands(hostname, admin_username, admin_password): # 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" ) return [ # Import dir for the HTTP→HTTPS force-redirect snippet. The # /api/furtka/https/force endpoint writes/removes a .caddyfile here # to toggle the redirect. Must exist before Caddy starts — the # Caddyfile's glob `import /etc/caddy/furtka.d/*.caddyfile` tolerates # an empty dir but not a missing one on every Caddy version, so we # create it up front and stay on the safe side. "install -d -m 0755 -o root -g root /etc/caddy/furtka.d", # The Caddyfile lives at /etc/caddy/Caddyfile per Caddy's convention # (systemd unit points there). Content comes from the shipped asset, # which we copy in at install time so updates that change routing # need a new release to refresh it. # # __FURTKA_HOSTNAME__ is the placeholder the asset carries in place # of the real hostname — Caddy's `tls internal` needs a named site # block to issue a leaf cert, and the hostname isn't known until # the user fills in the form. Self-updates re-apply the same # substitution against /etc/hostname (see updater._refresh_caddyfile). _write_file_cmd( "/etc/caddy/Caddyfile", _read_asset("Caddyfile").replace("__FURTKA_HOSTNAME__", hostname), ), # Initial status.json so Caddy doesn't 404 before furtka-status fires. _write_file_cmd("/var/lib/furtka/status.json", _read_asset("www/status.json")), nss_sed, # Resource manager bootstrap: extract tarball → versions//, # symlink current, install wrapper, systemctl-link unit files. *_resource_manager_commands(), # furtka.json depends on /opt/furtka/current/VERSION, so it has to # run after the resource-manager commands. _furtka_json_cmd(hostname), # Admin account for the Furtka web UI. Hashed here (werkzeug is # already in scope for the Flask webinstaller) and materialised # into /var/lib/furtka/users.json at mode 0600 on the target # partition — the installed core's auth.py picks it up on first # login. _users_json_cmd(admin_username, admin_password), ] 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"], s["username"], s["password"]), ], "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)