fix(furtka): pre-ISO audit fixes — chmod, Caddyfile refresh, unit linking

Five issues surfaced by the Phase-2 audit before the next ISO rebuild:

P1 (real blockers for a fresh install / self-update):

1. chmod +x furtka/assets/bin/furtka-status, furtka-welcome. They were
   mode 644 in git, so the tarball shipped them non-executable and every
   ExecStart referencing /opt/furtka/current/assets/bin/furtka-* would
   have failed on first boot with Permission denied.

2. apply_update now refreshes /etc/caddy/Caddyfile from the new version
   when the content differs, then reloads caddy. Without this, a release
   that changes Caddy routes silently stays on the old config.

3. apply_update now systemctl-links any new unit files shipped by the
   update, not just the five linked at install time. A future release
   that adds furtka-foo.service would otherwise never appear in
   /etc/systemd/system/.

P2 (hardening, not blockers today):

4. _resource_manager_commands now aborts the install if the tarball's
   VERSION file is empty — otherwise `mv "$staging" /opt/furtka/versions/`
   would move the staging dir in as a subdirectory and the symlink
   target would be invalid.

5. _extract_tarball passes filter='data' to tarfile.extractall on
   Python 3.12+ to catch symlink-escape / setuid / device-node tricks
   that the regex path-check can't see. Falls back silently on older
   interpreters.

Plus the CHANGELOG [Unreleased] section got filled in with the whole
Phase-1 + Phase-2 + UI-uplevel body so a 26.1-alpha tag cut off main
has meaningful release notes.

Test additions / updates:
- test_refresh_caddyfile_{copies_when_different,noops_if_source_missing}
- test_link_new_units_only_links_missing
- test_extract_tarball_uses_data_filter_when_available
- test_apply_update_happy_path now verifies the Caddyfile gets copied.
- test_resource_manager_extracts_to_versioned_slot verifies the
  empty-VERSION guard is present in the install command.

Paths now overridable via FURTKA_CADDYFILE_PATH + FURTKA_SYSTEMD_DIR so
tests can pin a tmpdir for these new fs operations.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-16 14:10:07 +02:00
parent 5c58eade1c
commit b8fdb62b41
7 changed files with 164 additions and 8 deletions

View file

@ -9,24 +9,30 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
### Added ### Added
- **Furtka self-update** (Phase 2). Tagging a release on main fires `.forgejo/workflows/release.yml`, which packages `furtka/` + `apps/` + a root-level `VERSION` file as `furtka-<tag>.tar.gz`, uploads it plus a `.sha256` + `release.json` to the Forgejo releases page, and makes the release available to running boxes. New CLI: `furtka update [--check]` + `furtka rollback`. New endpoints: `POST /api/furtka/update/check` + `/apply` + `GET /api/furtka/update/status`. UI: "Furtka updates" card on `/settings` shows installed vs latest, Update button runs the apply flow detached via `systemd-run`, progress polls `/update-state.json` served by Caddy so the mid-update API restart doesn't interrupt reporting. Atomic `/opt/furtka/current` symlink flip, auto-rollback on health-check failure post-restart, SHA256-verified downloads.
- **Per-app container image updates** (Phase 1). `POST /api/apps/<name>/update` runs `docker compose pull`, compares the running container's image digest to the just-pulled local image digest per service, and only restarts containers whose image actually changed. Update button on each installed-app row in `/apps`. Keeps `image: :latest` pins simple — no compose-file mutations.
- **Per-version install layout** on `/opt/furtka/`. Install now extracts the resource-manager payload to `/opt/furtka/versions/<VERSION>/` and creates `/opt/furtka/current` as an atomic symlink; updates flip the symlink in place and `systemctl link` every unit from the shipped `assets/systemd/` tree. Runtime JSON (`status.json`, `furtka.json`, `update-state.json`) moved to `/var/lib/furtka/` so self-updates never clobber it.
- **On-box UI uplevel** across three pages sharing one design system (`/style.css` served by Caddy). Redesigned landing page with a "Your apps" tile grid driven by `/api/apps`, a `fileshare` app tile that deep-links to `smb://<host>.local/files`, status tiles, and subtle "Coming next" links to `furtka.org`. `/apps` page renders real app icons inlined from each manifest's `icon.svg` (defensive SVG sanitiser — strips script/on*/javascript: content, 16 KB cap). New `/settings` page with About-this-box, Appearance, Furtka-updates, and Coming-next sections. Persistent top nav (Jakob's Law) on every page. Light-mode support via `prefers-color-scheme`.
- **Webinstaller step 2 (boot drive)** now shows size / type / health chips plus a "Recommended" badge on the auto-selected drive instead of a raw numeric score.
- **Forgejo branch protection on `main`** — no direct pushes except owner-whitelisted, required status checks (`CI / lint*`, `CI / test*`, `CI / validate-json*`), applied via the idempotent `ops/forgejo/apply-branch-protection.sh` script.
- **In-browser app settings**, so users no longer need SSH + `vim` to configure an app before first install. Manifest gains optional `settings` (name/label/description/type/required/default) and `description_long` fields. Installing a bundled app opens a form rendered from the manifest; installed apps grow a "Settings" button that edits merged values (password fields blank = keep current). API: `POST /api/apps/install` now accepts a `settings` object in the JSON body; new `GET`/`POST /api/apps/<name>/settings` for inspecting and updating an installed app. Password values never leave the server. - **In-browser app settings**, so users no longer need SSH + `vim` to configure an app before first install. Manifest gains optional `settings` (name/label/description/type/required/default) and `description_long` fields. Installing a bundled app opens a form rendered from the manifest; installed apps grow a "Settings" button that edits merged values (password fields blank = keep current). API: `POST /api/apps/install` now accepts a `settings` object in the JSON body; new `GET`/`POST /api/apps/<name>/settings` for inspecting and updating an installed app. Password values never leave the server.
- `nano` added to the installer package list so users have a beginner-friendly editor at the console/SSH (was `vim`-only, which `command not found`'d under Arch 4.x because it was actually missing from the package set too). - `nano` added to the installer package list so users have a beginner-friendly editor at the console/SSH (was `vim`-only, which `command not found`'d under Arch 4.x because it was actually missing from the package set too).
- `openssh` added explicitly to the installer package list and `sshd` added to enabled services. `archinstall: true` in archinstall 4.x did not actually install openssh-server, so the documented recovery path (SSH → edit `.env`) silently failed. - `openssh` added explicitly to the installer package list and `sshd` added to enabled services. `archinstall: true` in archinstall 4.x did not actually install openssh-server, so the documented recovery path (SSH → edit `.env`) silently failed.
- **Forgejo Actions runner** live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) with DinD sidecar — CI green end-to-end. Setup scripts in `ops/forgejo-runner/`.
- **Walking-skeleton live ISO** (`iso/build.sh`). Overlays an Arch `releng` profile with Flask + the webinstaller, bakes a systemd unit that auto-starts the wizard on boot, produces a hybrid BIOS/UEFI ISO via `mkarchiso` in a privileged `archlinux:latest` container. Tested booting under OVMF in Proxmox — wizard screens 13 respond at `http://<vm-ip>:5000`.
- **Public website at [furtka.org](https://furtka.org)** (`website/`). Hugo static site, English + German, served from `/var/www/furtka.org` on `forge-runner-01` via nginx. Upstream openresty proxy handles TLS. Intentionally minimal single-page copy while the project is pre-alpha. Deploy is `./website/deploy.sh` (rsync + remote Hugo build); one-time VM setup in `ops/nginx/setup-vm.sh`.
### Changed ### Changed
- Every on-box asset (landing page, settings page, style.css, status/welcome scripts, systemd units, Caddyfile) moved from inline Python string constants in `webinstaller/app.py` into real files under `furtka/assets/`. The installer reads them from disk at install time; the self-updater ships them in the release tarball.
- Settings-button label went from "Einstellungen" (prototyping leftover) to "Settings" — rest of the UI chrome is English.
- Keyboard layout at the TTY now follows the chosen installer language (`de``de`, `pl``pl`, `en``us`) instead of hardcoding `us`. Previously German users couldn't type `/`, `-`, or `=` at the recovery console. - Keyboard layout at the TTY now follows the chosen installer language (`de``de`, `pl``pl`, `en``us`) instead of hardcoding `us`. Previously German users couldn't type `/`, `-`, or `=` at the recovery console.
- `fileshare` app: `description_long` + `settings` (SMB_USER, SMB_PASSWORD) for the new settings form. Docker-level healthcheck from `dperson/samba` is disabled in the compose override — it timed out under normal operation and marked a working share "unhealthy" in `docker ps`. - `fileshare` app: `description_long` + `settings` (SMB_USER, SMB_PASSWORD) for the new settings form. Docker-level healthcheck from `dperson/samba` is disabled in the compose override — it timed out under normal operation and marked a working share "unhealthy" in `docker ps`.
- **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13. - **Project name finalized: Furtka.** Working title "Homebase" retired. Domain `furtka.org` registered via Strato 2026-04-13.
- Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`. - Managed gateway NS hostnames updated from `ns1.homebase.cloud` / `ns2.homebase.cloud` to `ns1.furtka.org` / `ns2.furtka.org`.
- Python package renamed from `homebase``furtka` in `pyproject.toml`. - Python package renamed from `homebase``furtka` in `pyproject.toml`.
### Added
- **Forgejo Actions runner** live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) with DinD sidecar — CI green end-to-end. Setup scripts in `ops/forgejo-runner/`.
- **Walking-skeleton live ISO** (`iso/build.sh`). Overlays an Arch `releng` profile with Flask + the webinstaller, bakes a systemd unit that auto-starts the wizard on boot, produces a hybrid BIOS/UEFI ISO via `mkarchiso` in a privileged `archlinux:latest` container. Tested booting under OVMF in Proxmox — wizard screens 13 respond at `http://<vm-ip>:5000`.
- **Public website at [furtka.org](https://furtka.org)** (`website/`). Hugo static site, English + German, served from `/var/www/furtka.org` on `forge-runner-01` via nginx. Upstream openresty proxy handles TLS. Intentionally minimal single-page copy while the project is pre-alpha. Deploy is `./website/deploy.sh` (rsync + remote Hugo build); one-time VM setup in `ops/nginx/setup-vm.sh`.
## [26.0-alpha] - 2026-04-13 ## [26.0-alpha] - 2026-04-13
First tagged snapshot. Pre-alpha — the installer does not yet boot, but the design is locked and the prototype components are shaped. First tagged snapshot. Pre-alpha — the installer does not yet boot, but the design is locked and the prototype components are shaped.

0
furtka/assets/bin/furtka-status Normal file → Executable file
View file

0
furtka/assets/bin/furtka-welcome Normal file → Executable file
View file

View file

@ -45,6 +45,8 @@ FORGEJO_HOST = os.environ.get("FURTKA_FORGEJO_HOST", "forgejo.sourcegate.online"
FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka") FORGEJO_REPO = os.environ.get("FURTKA_FORGEJO_REPO", "daniel/furtka")
_FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka")) _FURTKA_ROOT = Path(os.environ.get("FURTKA_ROOT", "/opt/furtka"))
_STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka")) _STATE_DIR = Path(os.environ.get("FURTKA_STATE_DIR", "/var/lib/furtka"))
_CADDYFILE_LIVE = Path(os.environ.get("FURTKA_CADDYFILE_PATH", "/etc/caddy/Caddyfile"))
_SYSTEMD_DIR = Path(os.environ.get("FURTKA_SYSTEMD_DIR", "/etc/systemd/system"))
class UpdateError(RuntimeError): class UpdateError(RuntimeError):
@ -187,6 +189,13 @@ def _extract_tarball(tarball: Path, dest: Path) -> str:
for member in tf.getmembers(): for member in tf.getmembers():
if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts: if member.name.startswith(("/", "..")) or ".." in Path(member.name).parts:
raise UpdateError(f"refusing tarball entry {member.name!r}") raise UpdateError(f"refusing tarball entry {member.name!r}")
# Python 3.12+ grew a stricter default filter; opt into it where
# available to catch symlink-escape / device-node / setuid tricks
# that our regex check can't see. Older Pythons fall back to the
# historical permissive behaviour.
try:
tf.extractall(dest, filter="data")
except TypeError:
tf.extractall(dest) tf.extractall(dest)
version_file = dest / "VERSION" version_file = dest / "VERSION"
if not version_file.is_file(): if not version_file.is_file():
@ -194,6 +203,35 @@ def _extract_tarball(tarball: Path, dest: Path) -> str:
return version_file.read_text().strip() return version_file.read_text().strip()
def _refresh_caddyfile(source: Path) -> bool:
"""Copy the shipped Caddyfile to /etc/caddy/ iff it differs. Returns True
if the file changed (so caddy needs more than a bare reload)."""
if not source.is_file():
return False
if _CADDYFILE_LIVE.is_file() and source.read_bytes() == _CADDYFILE_LIVE.read_bytes():
return False
_CADDYFILE_LIVE.parent.mkdir(parents=True, exist_ok=True)
shutil.copy(source, _CADDYFILE_LIVE)
return True
def _link_new_units(unit_dir: Path) -> list[str]:
"""`systemctl link` any unit file in unit_dir that isn't already symlinked
into /etc/systemd/system/. Returns the list of newly-linked unit names."""
if not unit_dir.is_dir():
return []
linked = []
for unit_file in sorted(unit_dir.iterdir()):
if unit_file.suffix not in (".service", ".timer"):
continue
target = _SYSTEMD_DIR / unit_file.name
if target.exists() or target.is_symlink():
continue
_run(["systemctl", "link", str(unit_file)])
linked.append(unit_file.name)
return linked
def write_state(stage: str, **extra) -> None: def write_state(stage: str, **extra) -> None:
state_path().parent.mkdir(parents=True, exist_ok=True) state_path().parent.mkdir(parents=True, exist_ok=True)
tmp = state_path().with_suffix(".tmp") tmp = state_path().with_suffix(".tmp")
@ -296,7 +334,14 @@ def apply_update(tarball: Path, version: str) -> None:
write_state("restarting", latest=version) write_state("restarting", latest=version)
try: try:
# Copy new Caddyfile into /etc/caddy/ if the release changed routes.
# reload always runs afterwards to flush the file-handle cache so the
# symlink flip takes effect even when Caddyfile itself didn't change.
_refresh_caddyfile(target / "assets" / "Caddyfile")
_run(["systemctl", "reload", "caddy"]) _run(["systemctl", "reload", "caddy"])
# Pick up any new systemd unit files added by this release. Existing
# linked units don't need relinking — daemon-reload rereads them.
_link_new_units(target / "assets" / "systemd")
_run(["systemctl", "daemon-reload"]) _run(["systemctl", "daemon-reload"])
_run(["systemctl", "try-restart", "furtka-reconcile.service"]) _run(["systemctl", "try-restart", "furtka-reconcile.service"])
_run(["systemctl", "restart", "furtka-api.service"]) _run(["systemctl", "restart", "furtka-api.service"])

View file

@ -22,6 +22,9 @@ def updater(tmp_path, monkeypatch):
monkeypatch.setenv("FURTKA_ROOT", str(tmp_path / "opt_furtka")) monkeypatch.setenv("FURTKA_ROOT", str(tmp_path / "opt_furtka"))
monkeypatch.setenv("FURTKA_STATE_DIR", str(tmp_path / "var_lib_furtka")) monkeypatch.setenv("FURTKA_STATE_DIR", str(tmp_path / "var_lib_furtka"))
monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock")) monkeypatch.setenv("FURTKA_LOCK_PATH", str(tmp_path / "update.lock"))
monkeypatch.setenv("FURTKA_CADDYFILE_PATH", str(tmp_path / "etc_caddy" / "Caddyfile"))
monkeypatch.setenv("FURTKA_SYSTEMD_DIR", str(tmp_path / "etc_systemd_system"))
(tmp_path / "etc_systemd_system").mkdir()
# Reload the module so the path constants pick up the env vars. # Reload the module so the path constants pick up the env vars.
import importlib import importlib
@ -107,6 +110,25 @@ def test_write_and_read_state_round_trip(updater):
assert "updated_at" in s assert "updated_at" in s
def _make_release_tarball(path: Path, version: str, caddyfile_body: str = "# caddy\n"):
"""Richer tarball with assets/Caddyfile + assets/systemd/ — enough for
apply_update's post-swap integration (caddy refresh, unit linking)."""
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
for name, content in [
("VERSION", f"{version}\n"),
("furtka/__init__.py", ""),
("apps/fileshare/manifest.json", "{}"),
("assets/Caddyfile", caddyfile_body),
("assets/systemd/furtka-api.service", "[Service]\nExecStart=/bin/true\n"),
]:
data = content.encode()
info = tarfile.TarInfo(name=name)
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
path.write_bytes(buf.getvalue())
def test_apply_update_happy_path(tmp_path, updater, monkeypatch): def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
# Set up an existing "26.0-alpha" current symlink so apply_update has # Set up an existing "26.0-alpha" current symlink so apply_update has
# something to swap out. # something to swap out.
@ -118,7 +140,7 @@ def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
current.symlink_to(versions / "26.0-alpha") current.symlink_to(versions / "26.0-alpha")
tar = tmp_path / "t.tar.gz" tar = tmp_path / "t.tar.gz"
_make_tarball(tar, "26.1-alpha") _make_release_tarball(tar, "26.1-alpha", caddyfile_body="# new caddy config\n")
# Stub the shell-out + health check — both succeed. # Stub the shell-out + health check — both succeed.
monkeypatch.setattr(updater, "_run", lambda cmd: None) monkeypatch.setattr(updater, "_run", lambda cmd: None)
@ -128,6 +150,8 @@ def test_apply_update_happy_path(tmp_path, updater, monkeypatch):
assert current.resolve() == (versions / "26.1-alpha").resolve() assert current.resolve() == (versions / "26.1-alpha").resolve()
assert (versions / "26.1-alpha" / "VERSION").read_text().strip() == "26.1-alpha" assert (versions / "26.1-alpha" / "VERSION").read_text().strip() == "26.1-alpha"
# P1-2: Caddyfile was copied into /etc/caddy/ from the new version.
assert updater._CADDYFILE_LIVE.read_text() == "# new caddy config\n"
state = updater.read_state() state = updater.read_state()
assert state["stage"] == "done" assert state["stage"] == "done"
assert state["version"] == "26.1-alpha" assert state["version"] == "26.1-alpha"
@ -163,6 +187,80 @@ def test_apply_update_rolls_back_on_health_check_failure(tmp_path, updater, monk
assert state["failed_version"] == "26.1-alpha" assert state["failed_version"] == "26.1-alpha"
def test_refresh_caddyfile_copies_when_different(updater, tmp_path):
# Fresh /etc/caddy/ — source wins.
src = tmp_path / "src"
src.write_text("# new\n")
assert updater._refresh_caddyfile(src) is True
assert updater._CADDYFILE_LIVE.read_text() == "# new\n"
# Same content — no-op.
assert updater._refresh_caddyfile(src) is False
def test_refresh_caddyfile_noops_if_source_missing(updater, tmp_path):
assert updater._refresh_caddyfile(tmp_path / "does-not-exist") is False
def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
unit_dir = tmp_path / "assets_systemd"
unit_dir.mkdir()
(unit_dir / "furtka-foo.service").write_text("[Service]\nExecStart=/bin/true\n")
(unit_dir / "furtka-bar.timer").write_text("[Timer]\nOnBootSec=1s\n")
(unit_dir / "ignored.txt").write_text("not a unit")
# Pretend furtka-foo is already linked — it must be skipped.
(updater._SYSTEMD_DIR / "furtka-foo.service").symlink_to("/dev/null")
seen = []
monkeypatch.setattr(updater, "_run", lambda cmd: seen.append(cmd))
linked = updater._link_new_units(unit_dir)
assert linked == ["furtka-bar.timer"]
# Only one systemctl link call — for the new timer, not the existing service.
assert len(seen) == 1
assert seen[0][:2] == ["systemctl", "link"]
assert seen[0][2].endswith("furtka-bar.timer")
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):
# Confirm we pass filter='data' to extractall on Python 3.12+; fall back
# cleanly on older runtimes. Capture the kwarg via a stub.
calls = []
real_open = updater.tarfile.open # capture before monkeypatching
class _Recorder:
def __init__(self, tarball):
self._tb = real_open(tarball, "r:gz")
def __enter__(self):
return self
def __exit__(self, *a):
self._tb.close()
def getmembers(self):
return self._tb.getmembers()
def extractall(self, *args, **kwargs):
calls.append(("extractall", args, kwargs))
# Force the TypeError branch when filter is passed, then re-run
# without — matches the older-Python fallback.
if "filter" in kwargs:
raise TypeError("old python")
return self._tb.extractall(*args)
tar = tmp_path / "t.tar.gz"
_make_release_tarball(tar, "26.9-alpha")
monkeypatch.setattr(updater.tarfile, "open", lambda *a, **kw: _Recorder(tar))
dest = tmp_path / "dest"
updater._extract_tarball(tar, dest)
# First call had filter=, second (fallback) didn't.
assert len(calls) == 2
assert calls[0][2] == {"filter": "data"}
assert calls[1][2] == {}
def test_apply_update_rejects_version_mismatch(tmp_path, updater, monkeypatch): def test_apply_update_rejects_version_mismatch(tmp_path, updater, monkeypatch):
versions = updater.versions_dir() versions = updater.versions_dir()
versions.mkdir(parents=True) versions.mkdir(parents=True)

View file

@ -75,6 +75,9 @@ def test_resource_manager_extracts_to_versioned_slot(install_cmds):
assert "/opt/furtka/versions" in extract_cmd assert "/opt/furtka/versions" in extract_cmd
assert "staging-" in extract_cmd # mktemp -d pattern assert "staging-" in extract_cmd # mktemp -d pattern
assert 'cat "$staging/VERSION"' in extract_cmd assert 'cat "$staging/VERSION"' in extract_cmd
# An empty VERSION file must abort the install instead of silently
# moving the staging dir into versions/ as a subdir.
assert '[ -n "$ver" ]' in extract_cmd
assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd assert 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' in extract_cmd

View file

@ -255,6 +255,10 @@ def _resource_manager_commands():
"staging=$(mktemp -d /opt/furtka/versions/staging-XXXXXX) && " "staging=$(mktemp -d /opt/furtka/versions/staging-XXXXXX) && "
f'printf %s {payload_b64} | base64 -d | tar -xzf - -C "$staging" && ' f'printf %s {payload_b64} | base64 -d | tar -xzf - -C "$staging" && '
'ver=$(cat "$staging/VERSION") && ' '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" && ' 'mv "$staging" "/opt/furtka/versions/$ver" && '
'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current' 'ln -sfn "/opt/furtka/versions/$ver" /opt/furtka/current'
) )