# Changelog All notable changes to Furtka will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, release 0, alpha stage). ## [Unreleased] ## [26.17-alpha] - 2026-05-11 ### Added - **App-to-app dependencies.** Manifests gain an optional `requires` array; each entry names a provider app plus two optional hook scripts that live in the *provider's* folder. `on_install` runs once via `docker compose exec` against the provider's running container while the consumer is being installed (use case: `mosquitto_passwd` a new MQTT user for the consumer). `on_start` runs every boot during reconcile, before the consumer's container starts (use case: make sure the user still exists after a Mosquitto wipe). Hook stdout parses as `KEY=VALUE` lines and optional `FURTKA_JSON: {…}` sentinel lines, both validated against the existing `SETTING_NAME` regex; the values get merged into the consumer's `.env` (hook wins on conflict) and the placeholder-secret check runs again over the merged file so a hook returning `MQTT_PASS=changeme` is refused the same way an unedited `.env.example` is. - **`POST /api/apps/install/plan`.** New read-only endpoint that returns the topo-sorted install order for a target app plus per-app summaries (display_name, version, has_settings, installed flag). The catalog UI calls this before opening the settings dialog so it can show a confirm modal — "Installing zigbee2mqtt also installs Mosquitto" — before anything mutates. Circular dependencies surface as `400 {error: "circular dependency: A -> B -> A"}`; missing providers as `400 {error: "required app 'X' not found …"}`. - **`/var/lib/furtka/install-plan.json`** (overridable via `FURTKA_INSTALL_PLAN`). The HTTP install endpoint writes this before it spawns the systemd-run background job so the runner knows the full chain to pull → create volumes → fire hooks → `compose up` for in plan order. The runner consumes the file after reading so a stale plan from a previous install can't accidentally steer the next one. ### Changed - **`furtka reconcile` now visits apps in dependency order, not alphabetical.** Topo-sort over `requires` puts providers before consumers so a consumer's `on_start` hook can talk to an already-up provider. Within a tier, ties stay alphabetical so boot logs are still deterministic across reboots. Apps with unresolvable `requires` (missing provider) are visited last; the per-app error-isolation in reconcile then catches them without aborting the whole sweep. - **`POST /api/apps/install` requires `confirm_dependencies: true`** when installing a named app would pull in transitive providers. Without the flag, the endpoint returns `409` plus the full plan body so the UI can render the confirm dialog without a second round-trip. Lone-target installs (no transitive deps) keep the existing one-click flow — no UX change for `fileshare`-style standalone apps. - **`furtka app install ` and the web UI now install transitive dependencies automatically.** `furtka app install /path/to/dir` stays as today (single-app, dev/test workflow). - **`compose_exec` and `compose_exec_script` helpers** in `furtka/dockerops.py`. Both pass `-T` (no TTY) so they work from the install runner and from reconcile; both raise `DockerError` on non-zero exit or timeout. `compose_exec_script` streams the script body via stdin to `sh -s` so hooks don't need to be baked into the provider's container image. ### Notes - Hook target service: v1 auto-picks the *first* service in the provider's compose config. Works for Mosquitto, Postgres, Redis. Multi-service providers (Authentik server+worker) will need an optional `service` field on the requirement entry; deferred until a real case lands. - Hook timeouts: `on_install` 60 s, `on_start` 30 s. Hardcoded for v1 — revisit if a DB seed legitimately needs longer. - Removing an app is now blocked (`409 {dependents: […]}` from the API, exit 2 from the CLI) when other installed apps require it. ## [26.16-alpha] - 2026-05-10 ### Added - **Failed-login rate limit on `/login`.** A new in-memory `LoginAttempts` store in `furtka/auth.py` blocks brute-force attempts after 10 failures in 15 minutes from the same (username, IP) pair, with a 15-minute lockout. Successful logins clear the counter; a `systemctl restart furtka` clears any stuck lockout — fine for an alpha single-user box. Tuple-keying means a flood from one source IP can't lock the admin out from elsewhere; an attacker can rotate IPs to keep probing forever, but each attempt still eats the PBKDF2 cost. Locked attempts get a `Retry-After` header so the UI can render the cooldown. - **Live-ISO boot USB is filtered out of the install drive picker.** On bare-metal installs, `lsblk` reports the USB stick the live ISO booted from as `TYPE=disk`, so it showed up in the picker alongside the real install target — a user could in theory pick the USB they had just booted from. `webinstaller/drives.py` now resolves `/run/archiso/bootmnt` via `findmnt`, walks it up to its parent disk via `lsblk -no PKNAME`, and drops that disk before scoring. On a normal (non-live) box `/run/archiso/bootmnt` does not exist and the picker is unchanged. ### Changed - **furtka.org homepage rebuild.** Adopted the visual feel of Pascal's prototype while keeping Furtka's voice, brand palette, and bilingual structure: Three.js wireframe torus-knot behind the hero (color + opacity tied to the existing `--accent` CSS var so light and dark modes share one scene), scroll-driven camera zoom + tilt, GSAP + ScrollTrigger card reveals, Lenis smooth scroll, gradient wordmark, drop-shadow glow in dark mode, and a pulsing CTA pointing at `/releases`. "What works today" / "What's coming next" lists moved from markdown bullets into front-matter arrays and now render as scroll-reveal cards. All vendor JS (Three.js r128, GSAP 3.12.2 + ScrollTrigger, Lenis 1.0.33) is vendored locally under `website/assets/js/vendor/`, fingerprinted with SRI, gated to the homepage only, deferred so first paint isn't blocked, and early-returned on `prefers-reduced-motion`. - **Static-asset gzip on the furtka.org nginx (config only — needs a deploy on forge-runner-01).** Default nginx only gzips `text/html`, so the homepage HTML was the only asset coming back compressed. The ~600 KB `three.min.js` bundle (and the hashed CSS) were being shipped uncompressed across the public openresty proxy. `gzip_types` in `ops/nginx/furtka.org.conf` now covers css/js/json/xml/svg/woff2. Needs `sudo ops/nginx/setup-vm.sh` on forge-runner-01 to take effect — the site-deploy workflow only rebuilds Hugo, it doesn't touch the nginx config. ## [26.15-alpha] - 2026-04-21 ### Fixed - **HTTPS is now opt-in; fresh installs no longer hit unbypassable SEC_ERROR_BAD_SIGNATURE.** Every version since 26.5 shipped a Caddyfile with a `__FURTKA_HOSTNAME__.local { tls internal }` site block, so Caddy auto-generated a self-signed root CA + intermediate + leaf on first boot. That worked for first-time-ever users, but every reinstall (or second Furtka box on the same LAN) produced a new CA with the **same intermediate CN** (`Caddy Local Authority - ECC Intermediate` — Caddy hardcodes it). Any browser that had ever trusted an earlier Furtka CA got a cached intermediate with mismatched keys, then Firefox's cert lookup substituted the cached intermediate when validating the new box's leaf → the signature check failed → `SEC_ERROR_BAD_SIGNATURE`, which Firefox has no "Advanced → Accept Risk" bypass for. - Removed the hostname site block from the default Caddyfile. Fresh installs serve `:80` only; visiting `https://furtka.local` now yields a clean connection-refused instead of the crypto fault. - Added top-level `import /etc/caddy/furtka-https.d/*.caddyfile`. The `/settings` HTTPS toggle (via `furtka.https.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) — and removes both on disable. Caddy reloads after the pair-swap; failure rolls both back. - Webinstaller creates `/etc/caddy/furtka-https.d/` during post-install alongside the existing `furtka.d/`. - `updater._refresh_caddyfile` runs a 26.14 → 26.15 migration: if the box already had the redirect snippet on disk (user had explicitly enabled "Force HTTPS" under the old regime), the migration also writes the new listener snippet so HTTPS keeps working across the upgrade. - **`status.force_https` now reads the listener snippet, not the redirect snippet.** A lone redirect without a `:443` listener wouldn't actually serve HTTPS, so the listener file is the authoritative "HTTPS is on" signal. The UI on `/settings` sees the correct state as a result. Known remaining UX wart: a browser that trusted a previous Furtka box still sees `BAD_SIGNATURE` when visiting this box's `https://` after enabling HTTPS here — the fixed intermediate CN is a Caddy-side limitation we can't fix from Furtka. Fresh installs on a browser that never visited another Furtka box work correctly. Workaround: `about:networking#sts` → Forget → clear `cert9.db`. ## [26.14-alpha] - 2026-04-21 ### Fixed - **Landing page and `/settings/` were silently bypassing the auth guard.** Since 26.11 shipped login, the Caddyfile only reverse-proxied `/api/*`, `/apps*`, `/login*`, and `/logout*` to Python. Everything else — including `/` and `/settings/` — fell through to Caddy's catch-all `file_server` and was served straight from `assets/www/` without ever hitting the session check. The effect: a LAN visitor saw the box's hostname, IP, Furtka version, and the buttons for Update-now / Reboot / HTTPS-toggle. The API calls those buttons fired were all 401-auth-gated so actions didn't land, but the information leak and the "looks open" UX was a real bug. Caught in the 26.13 SSH test session when the user noticed Logout only showed up on `/apps`. Now Caddy routes `/` and `/settings*` through Python; a new `_serve_static_www` handler checks the session cookie, redirects to `/login` if unauthed, and reads the HTML from `assets/www/` otherwise. Catch-all still serves `/style.css`, `/rootCA.crt`, and the runtime JSON files publicly — those don't need auth. - **Logout link now shows on every authed page, not just `/apps`.** The static HTML for `/` and `/settings/` maintained their own nav separate from `_HTML` in `api.py`, so they never got the Logout entry when it was added in 26.11. Both nav bars now include it plus an inline `doLogout()` that POSTs `/logout` and bounces to `/login`, matching the pattern in `_HTML`. ## [26.13-alpha] - 2026-04-21 ### Fixed - **Upgrade path from pre-auth releases actually works.** 26.11-alpha introduced `from werkzeug.security import ...` in `furtka/auth.py`, but werkzeug isn't installed on the target system — core runs as system Python with stdlib only, and `flask>=3.0` in `pyproject.toml` is never pip-installed on the box. Fresh boxes from the 26.11/26.12 ISO without a manually-installed werkzeug crashed on import; boxes upgrading from pre-26.11 got double-broken by that plus the health check below. Replaced the werkzeug dependency with a stdlib-only `furtka/passwd.py` that uses `hashlib.pbkdf2_hmac` for new hashes and parses werkzeug's `scrypt:N:r:p$salt$hex` format for backward compatibility — existing `users.json` files created on the rare boxes that did have werkzeug keep working after this upgrade, no re-setup needed. `from werkzeug.security import ...` is gone from the import chain entirely; `pyproject.toml`'s flask dep stays only for the live-ISO webinstaller. - **Self-update no longer auto-rolls-back when crossing the auth boundary.** `updater._health_check` pinged `/api/apps` and demanded a 200, which meant every 26.10 → 26.11+ upgrade hit the post-restart check, got a 401 (auth guard), and treated that as "server dead" → rollback. Now any 2xx–4xx response counts as "server alive"; only connection-level failures or 5xx fail the check. 5xx still fails rollback because that means the new process is up but broken. - **Install lock closes its race window.** `POST /api/apps/install` used to release the fcntl lock immediately after the sync pre-validation so the systemd-run child could re-acquire it — leaving a tiny gap where a second POST could slip in, pass the lock check, and return 202. Both child processes would start, one would win the in-child lock, the other would die silently. Now the API also reads `install-state.json` and refuses with 409 if the stage is non-terminal (`pulling_image`, `creating_volumes`, `starting_container`). The fcntl lock stays as belt-and-suspenders. ## [26.12-alpha] - 2026-04-21 ### Changed - **App-Install geht async mit Live-Progress.** `POST /api/apps/install` returnt jetzt `202 Accepted` nach der synchronen Pre-Validation (Source auflösen, Files kopieren, `.env` schreiben, Placeholder- und Path-Checks). Den eigentlichen Docker-Teil (`compose pull` → volumes → `compose up`) dispatched der Handler als `systemd-run --unit=furtka-install-` Hintergrund-Job, der seine Phase in `/var/lib/furtka/install-state.json` schreibt. Neues `GET /api/apps/install/status` für UI-Polling. Das Install-Modal zeigt jetzt live "Image wird heruntergeladen…" → "Speicherbereiche werden erstellt…" → "Container wird gestartet…" statt ~30 Sekunden totem "Installing…". Muster 1:1 parallel zu `/api/catalog/sync/apply` und `/api/furtka/update/apply`. Neue CLI- Subcommand `furtka app install-bg ` (intern, von der API aufgerufen); `furtka app install` für Terminal-User bleibt synchron. Die Reinstall-Taste in der App-Liste pollt ebenfalls den Install-Status und spiegelt die Phase im Button-Text. ## [26.11-alpha] - 2026-04-21 ### Added - **Login-auth for the Furtka web UI.** Every `/apps`, `/api/*`, `/`, and `/settings/` route now requires a signed-in session. New `/login` page serves a username/password form; `POST /login` validates against `/var/lib/furtka/users.json` (werkzeug PBKDF2- hashed), sets a `furtka_session` cookie (`HttpOnly`, `SameSite= Strict`, 7-day TTL), and redirects to `/apps`. `POST /logout` revokes the server-side session and clears the cookie. Unauthenticated HTML requests get a 302 to `/login`; unauthenticated API requests get 401 JSON. The old "No authentication on this UI yet" banner is gone; the `/apps` header picks up a `Logout` link instead. - **First-run setup fallback for upgrade-path boxes.** Boxes upgrading from 26.10-alpha have no `users.json` yet — on the first visit `/login` renders a setup form (username + password + password-confirm) that creates the admin record on submit. Fresh installs skip this: the webinstaller writes `users.json` during the chroot post-install step using the step-1 password, so the first browser visit after boot goes straight to the login form. - **Caddy proxy routes `/login` and `/logout`.** `assets/Caddyfile` gets two new `handle` blocks in the shared `(furtka_routes)` snippet so both the `:80` block and the `hostname.local, hostname` HTTPS block forward the auth endpoints to the stdlib server on `127.0.0.1:7000`. Without this Caddy would serve a 404 from the static file server. ### Fixed - `tests/test_installer.py` ruff-format nit — the 26.10-alpha release commit had a misformatted list literal that failed `ruff format --check`. Caught when the Release page on Forgejo showed a red CI badge for the tag. - `pyproject.toml` version string bumped from the stale 26.8-alpha to 26.11-alpha. Release pipeline uses `GITHUB_REF_NAME` as source of truth for the artefact name, but having the two agree matters for local dev runs that read `pyproject.toml`. ## [26.10-alpha] - 2026-04-21 ### Added - **Remove-USB-stick hint on the installer's post-install screen.** `webinstaller/templates/install/rebooting.html` now shows a bold "Remove the USB stick now" line before the reboot, plus a muted fallback explaining the BIOS boot-menu keys (F11/F12/Esc) if the machine boots back into the installer anyway. Caught on the first bare-metal test (Medion i5-4gen, 2026-04-21) where the box didn't boot the installed system without manual BIOS-order changes. - **New `path` setting type for app manifests.** Apps can now declare a setting with `"type": "path"` whose value is an absolute filesystem path on the host; docker-compose bind-mounts it via the usual `.env` substitution (`${MEDIA_PATH}:/media`). Unlocks media/data-heavy apps (Jellyfin, later Paperless/Nextcloud/Immich) where the user points at an existing folder instead of copying everything into a Docker volume. The install form renders path settings as a plain text input with a `/mnt/…` placeholder hint. - **Server-side path validation.** Both `install_from()` and `update_env()` refuse values that aren't absolute, don't exist, aren't directories, or resolve (after `Path.resolve()`) into a system-path deny-list (`/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`, `/bin`, `/sbin`, `/usr/bin`, `/usr/sbin`, `/var/lib/furtka`). Catches `/mnt/../etc`-style traversal too. Error messages surface in the existing install/edit modal error line. ## [26.9-alpha] - 2026-04-21 ### Fixed - Landing-page app tiles with an `open_url` now open in a new tab (`target="_blank" rel="noopener"`), matching the Open button behaviour on `/apps`. Without this, clicking "Uptime Kuma" on the home screen replaced Furtka itself with the Kuma admin page. Internal links (the `Manage →` fallback for apps without an `open_url`) still open in the same tab. - `scripts/publish-release.sh` no longer fails the whole release when the ISO upload hits a Forgejo proxy 504. The core tarball + sha256 + release.json (which running boxes need for self-update) are uploaded first and the ISO is attempted last as a best-effort; a 504 now logs a warning and exits 0 so the release page still publishes. Surfaced by the 26.8-alpha cut: the tarball landed but the ~1 GB ISO upload timed out at the Forgejo reverse proxy. ### Changed - `furtka app list --json` now mirrors `/api/apps` field-for-field — previously the CLI emitted a slim projection missing `description_long`, `open_url`, and `settings`. Anyone piping the CLI output into jq for automation was seeing an incomplete view. ## [26.8-alpha] - 2026-04-20 ### Added - **Live-installer ISO attached to the Forgejo release page.** `.forgejo/workflows/release.yml` moves to the self-hosted runner, builds both the self-update tarball and the ISO, and `scripts/publish-release.sh` uploads the ISO as a fourth release asset (`furtka-.iso`) alongside the existing tarball + sha256 + release.json. Fresh-install users can now grab the ISO from the release page instead of hunting through `build-iso.yml` artifact retention windows. ISO build step is `continue-on-error` so an ISO flake doesn't hold back the core tarball that running boxes need for self-update. - **Reboot + Shut down buttons on `/settings`.** Replaces the two "Coming next" placeholders with real actions backed by `POST /api/furtka/power` (`{"action": "reboot" | "poweroff"}`). Handler kicks a delayed `systemd-run --on-active=3s systemctl {reboot|poweroff}` so the HTTP response reaches the browser before the kernel loses network. Each button opens a native confirm dialog first (reboot: "back in ~30 s", shut down: "need to press the physical power button"), then the UI swaps to a status line and — after a reboot — polls `/furtka.json` until the box is back, reloading the page automatically. No auth (same posture as install/remove). - **Manifest `open_url` field + Open button in `/apps` and on the landing page.** Apps declare a URL template (e.g. `smb://{host}/files` for fileshare, `http://{host}:3001/` for Uptime Kuma); the UI substitutes `{host}` with the current browser's hostname at render time so the link follows however the user reached Furtka (furtka.local, raw IP, a future reverse-proxy hostname). The landing page's hardcoded `if app.name === 'fileshare'` special-case is gone — any app with an `open_url` in its manifest now gets a proper "Open" link. The core seed `apps/fileshare/manifest.json` bumps to v0.1.2 to carry it. ### Changed - `.btn` CSS class introduced so an `` rendered-as-button lines up with its `