All checks were successful
Build ISO / build-iso (push) Successful in 17m23s
CI / lint (push) Successful in 27s
CI / test (push) Successful in 1m2s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 15s
Release / release (push) Successful in 11m34s
Every Furtka since 26.5 shipped a Caddyfile with a
`__FURTKA_HOSTNAME__.local { tls internal }` site block, so every
first boot auto-generated a fresh self-signed CA + intermediate +
leaf. That worked for the first-ever Furtka user, but every reinstall
(or second box on the same LAN) produced a new CA whose intermediate
shared the fixed CN `Caddy Local Authority - ECC Intermediate` with
the previous one. Firefox caches intermediates by CN across profiles
— even private windows share cert9.db — so any visitor who had
trusted an older Furtka's CA got a cached intermediate with
mismatched keys when they hit the new box, producing
`SEC_ERROR_BAD_SIGNATURE`. Unlike UNKNOWN_ISSUER, Firefox has NO
"Advanced → Accept Risk" bypass for BAD_SIGNATURE, so fresh-install
boxes were effectively unreachable over HTTPS in any browser that
had ever seen a previous Furtka.
Validated live on the .46 test VM: fresh 26.14 ISO install → Firefox
hits BAD_SIGNATURE on https://furtka.local/ (even in private mode).
Chromium bypasses it via mDNS failure but the issue is the same.
openssl verify on the box confirms the chain is internally valid —
this is purely client-side cache pollution across boxes.
Fix:
- assets/Caddyfile: removed the hostname site block. Default install
serves :80 only — https://furtka.local connection-refuses, which is
a normal error every browser handles instead of the unbypassable
crypto fault. Added top-level import of
/etc/caddy/furtka-https.d/*.caddyfile so the /settings HTTPS toggle
can drop a listener snippet there when a user explicitly opts in.
- furtka/https.py: set_force_https now writes TWO snippets atomically
— the top-level hostname + tls internal block (enables :443) and
the :80-scoped redirect (forces HTTP→HTTPS). Disable removes both.
Reload failure rolls both back. Added _read_hostname + _https_snippet_content
helpers with `/etc/hostname` → 'furtka' fallback so a missing
hostname file doesn't produce an empty site block Caddy rejects.
- furtka/https.py::status: force_https now reads the listener
snippet (was reading the redirect snippet). A redirect without a
listener isn't actually HTTPS being served, so the listener is the
authoritative "HTTPS is on" signal.
- furtka/updater.py: new _maybe_migrate_preserve_https hook runs
inside _refresh_caddyfile on the 26.14 → 26.15 transition. If the
box had the redirect snippet on disk (user had opted into HTTPS
under the old regime), it writes the new listener snippet too so
HTTPS keeps working after the Caddyfile swap removes the hostname
block.
- webinstaller/app.py: post-install creates /etc/caddy/furtka-https.d/
alongside /etc/caddy/furtka.d/ so the glob import can't trip an
older Caddy on a missing path during the first reload.
Live-tested on .46: set_force_https(True) writes both snippets, Caddy
reloads, :443 listener comes up with fresh CA, curl -k returns 302,
HTTP 301-redirects. set_force_https(False) removes both snippets
atomically, :443 goes back to connection-refused.
Tests: test_https.py expanded from 13 to 15 cases. Toggle-on asserts
both snippets written + hostname substituted. Toggle-off asserts
both removed. Rollback cases verify BOTH snippets restore on reload
failure. New test_https_snippet_content_has_tls_internal_and_routes
locks the exact shape of the listener block.
test_webinstaller_assets.py: updated two old asserts that assumed
hostname block was in Caddyfile; new test_post_install_creates_https_snippet_dir
guards the new directory.
276 tests pass, ruff check + format clean.
Known remaining wart (documented in CHANGELOG): a browser that
trusted a prior Furtka CA still hits BAD_SIGNATURE on this box's
HTTPS after enabling it, because the fixed intermediate CN is a
Caddy-side limitation. Workaround: clear cert9.db or visit in a
fresh profile. Won't affect end users with one Furtka box ever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
647 lines
25 KiB
Python
647 lines
25 KiB
Python
# 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/<ver>/ + 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/<ver>/.
|
|
# 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 <<EOF\n"
|
|
"{\n"
|
|
f' "hostname": "{hostname}",\n'
|
|
' "install_date": "$(date -Iseconds)",\n'
|
|
' "version": "$(cat /opt/furtka/current/VERSION 2>/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",
|
|
# Parallel dir for the top-level HTTPS-listener snippet, written
|
|
# by /api/furtka/https/force (26.15-alpha+) when the user opts
|
|
# into HTTPS. Empty by default so fresh installs never generate
|
|
# a tls internal cert — that was the 26.14 regression where
|
|
# Firefox hit unbypassable SEC_ERROR_BAD_SIGNATURE because
|
|
# Caddy's fixed intermediate-CN clashed with any cached trust
|
|
# from a previously-reinstalled Furtka box.
|
|
"install -d -m 0755 -o root -g root /etc/caddy/furtka-https.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/<ver>/,
|
|
# 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 <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"], 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)
|